diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index b1ac2b7..954b656 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -1,4 +1,3 @@
-import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
@@ -6,6 +5,7 @@ plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
+ kotlin("plugin.serialization") version libs.versions.kotlin.get()
}
kotlin {
@@ -14,11 +14,21 @@ kotlin {
jvmTarget.set(JvmTarget.JVM_11)
}
}
-
+
sourceSets {
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose)
+ implementation("androidx.navigation:navigation-compose:2.8.8")
+ implementation("androidx.datastore:datastore-preferences:1.2.1")
+ implementation("androidx.datastore:datastore-preferences-core:1.2.1")
+ implementation("org.jetbrains.compose.material3:material3:1.11.0-alpha07")
+ implementation("io.coil-kt.coil3:coil-compose:3.4.0")
+ implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
+ implementation("su.reya:nostr-sdk-kmp:0.2.3")
+ implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
+ implementation("androidx.lifecycle:lifecycle-process:2.8.0")
+ implementation("io.github.alexzhirkevich:qrose:1.1.2")
}
commonMain.dependencies {
implementation(libs.compose.runtime)
diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index 26403a7..3fed69d 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -1,6 +1,16 @@
+
+
+
+
+
+
+
+
+ android:name=".MainActivity"
+ android:exported="true">
-
+
\ No newline at end of file
diff --git a/composeApp/src/androidMain/composeResources/drawable/avatar.png b/composeApp/src/androidMain/composeResources/drawable/avatar.png
new file mode 100644
index 0000000..8805020
Binary files /dev/null and b/composeApp/src/androidMain/composeResources/drawable/avatar.png differ
diff --git a/composeApp/src/androidMain/composeResources/drawable/coop.xml b/composeApp/src/androidMain/composeResources/drawable/coop.xml
new file mode 100644
index 0000000..6ee1e1f
--- /dev/null
+++ b/composeApp/src/androidMain/composeResources/drawable/coop.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_arrow_back.xml b/composeApp/src/androidMain/composeResources/drawable/ic_arrow_back.xml
new file mode 100644
index 0000000..fd02352
--- /dev/null
+++ b/composeApp/src/androidMain/composeResources/drawable/ic_arrow_back.xml
@@ -0,0 +1,10 @@
+
+
+
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_avatar.xml b/composeApp/src/androidMain/composeResources/drawable/ic_avatar.xml
new file mode 100644
index 0000000..36234ee
--- /dev/null
+++ b/composeApp/src/androidMain/composeResources/drawable/ic_avatar.xml
@@ -0,0 +1,9 @@
+
+
+
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..d970f0f
--- /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..640b590
--- /dev/null
+++ b/composeApp/src/androidMain/composeResources/drawable/ic_close_small.xml
@@ -0,0 +1,9 @@
+
+
+
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/composeResources/drawable/ic_plus.xml b/composeApp/src/androidMain/composeResources/drawable/ic_plus.xml
new file mode 100644
index 0000000..6bff660
--- /dev/null
+++ b/composeApp/src/androidMain/composeResources/drawable/ic_plus.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_qr.xml b/composeApp/src/androidMain/composeResources/drawable/ic_qr.xml
new file mode 100644
index 0000000..221ce6e
--- /dev/null
+++ b/composeApp/src/androidMain/composeResources/drawable/ic_qr.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_scanner.xml b/composeApp/src/androidMain/composeResources/drawable/ic_scanner.xml
new file mode 100644
index 0000000..57b0313
--- /dev/null
+++ b/composeApp/src/androidMain/composeResources/drawable/ic_scanner.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_search.xml b/composeApp/src/androidMain/composeResources/drawable/ic_search.xml
new file mode 100644
index 0000000..4aab985
--- /dev/null
+++ b/composeApp/src/androidMain/composeResources/drawable/ic_search.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_send.xml b/composeApp/src/androidMain/composeResources/drawable/ic_send.xml
new file mode 100644
index 0000000..f1a96c4
--- /dev/null
+++ b/composeApp/src/androidMain/composeResources/drawable/ic_send.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt
index 6e03338..8185f79 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt
@@ -1,49 +1,254 @@
package su.reya.coop
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.safeContentPadding
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
-import androidx.compose.runtime.*
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.expressiveLightColorScheme
+import androidx.compose.material3.rememberModalBottomSheetState
+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
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
-import org.jetbrains.compose.resources.painterResource
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+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
+import androidx.navigation.toRoute
+import kotlinx.coroutines.launch
+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.MyQrScreen
+import su.reya.coop.screens.NewChatScreen
+import su.reya.coop.screens.NewIdentityScreen
+import su.reya.coop.screens.OnboardingScreen
+import su.reya.coop.screens.RelayScreen
+import su.reya.coop.screens.ScanScreen
-import coop.composeapp.generated.resources.Res
-import coop.composeapp.generated.resources.compose_multiplatform
+val LocalNostrViewModel = staticCompositionLocalOf {
+ error("No NostrViewModel provided")
+}
+val LocalSnackbarHostState = staticCompositionLocalOf {
+ error("No SnackbarHostState provided")
+}
+
+val LocalNavController = staticCompositionLocalOf {
+ error("No NavController provided")
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
-@Preview
fun App() {
- MaterialTheme {
- var showContent by remember { mutableStateOf(false) }
- Column(
- modifier = Modifier
- .background(MaterialTheme.colorScheme.primaryContainer)
- .safeContentPadding()
- .fillMaxSize(),
- horizontalAlignment = Alignment.CenterHorizontally,
+ val context = LocalContext.current
+ val navController = rememberNavController()
+ val scope = rememberCoroutineScope()
+ val darkMode = isSystemInDarkTheme()
+
+ // Snackbar
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ // Initialize Nostr View Model and Secret Store
+ val secretStore = remember { SecretStore(context) }
+ val viewModel: NostrViewModel = viewModel { NostrViewModel(NostrManager.instance, secretStore) }
+
+ // 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)
+ }
+
+ darkMode -> darkColorScheme()
+ else -> expressiveLightColorScheme()
+ }
+
+ LaunchedEffect(Unit) {
+ viewModel.errorEvents.collect { message ->
+ snackbarHostState.showSnackbar(message)
+ }
+ }
+
+ MaterialExpressiveTheme(
+ colorScheme = colorScheme,
+ ) {
+ CompositionLocalProvider(
+ LocalNostrViewModel provides viewModel,
+ LocalSnackbarHostState provides snackbarHostState,
+ LocalNavController provides navController,
) {
- Button(onClick = { showContent = !showContent }) {
- Text("Click me!")
+ val emptySecret by viewModel.emptySecret.collectAsState(initial = null)
+ val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState()
+ val sheetState = rememberModalBottomSheetState()
+
+ LaunchedEffect(emptySecret) {
+ // Navigate to the home screen if the secret is already set
+ if (emptySecret == false) {
+ navController.navigate(Screen.Home) {
+ popUpTo(Screen.Onboarding) { inclusive = true }
+ }
+ }
}
- AnimatedVisibility(showContent) {
- val greeting = remember { Greeting().greet() }
- Column(
- modifier = Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally,
+
+ // Show loading screen while initializing
+ if (emptySecret == null) return@CompositionLocalProvider
+
+ // Show the relay setup dialog if the msg relay list is empty
+ if (isRelayListEmpty) {
+ ModalBottomSheet(
+ onDismissRequest = { viewModel.dismissRelayWarning() },
+ sheetState = sheetState,
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
) {
- Image(painterResource(Res.drawable.compose_multiplatform), null)
- Text("Compose: $greeting")
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(0.5f)
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = "Messaging Relays are required",
+ style = MaterialTheme.typography.headlineSmallEmphasized.copy(
+ fontWeight = FontWeight.SemiBold,
+ ),
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(
+ text = "Coop cannot found your messaging relays. To send and receive messages on Coop, you need to set up at least one messaging relay.",
+ style = MaterialTheme.typography.bodyLarge
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(
+ text = "Please click the button below to continue with the default set of relays. You can always change them later in the settings.",
+ style = MaterialTheme.typography.bodyLarge.copy(
+ fontStyle = FontStyle.Italic,
+ ),
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ Button(
+ onClick = {
+ scope.launch {
+ viewModel.useDefaultMsgRelayList()
+ sheetState.hide()
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(ButtonDefaults.MediumContainerHeight),
+ ) {
+ Text(
+ text = "Continue",
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ )
+ }
+ }
+ }
+ }
+
+ NavHost(
+ navController = navController,
+ startDestination = if (emptySecret == false) Screen.Home else Screen.Onboarding
+ ) {
+ composable { backStackEntry ->
+ OnboardingScreen(
+ onOpenImport = { navController.navigate(Screen.Import) },
+ onOpenNew = { navController.navigate(Screen.NewIdentity) }
+ )
+ }
+ composable { backStackEntry ->
+ val isCreating by viewModel.isCreating.collectAsState()
+
+ ImportScreen(
+ isLoading = isCreating,
+ onBack = { navController.popBackStack() },
+ onSave = { secret ->
+ viewModel.importIdentity(secret)
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val isCreating by viewModel.isCreating.collectAsState()
+
+ NewIdentityScreen(
+ isLoading = isCreating,
+ onBack = { navController.popBackStack() },
+ onSave = { name, bio, uri ->
+ val contentType = uri?.let { context.contentResolver.getType(it) }
+ val picture = uri?.let {
+ context.contentResolver.openInputStream(it)?.use { input ->
+ input.readBytes()
+ }
+ }
+ viewModel.createIdentity(name, bio, picture, contentType)
+ }
+ )
+ }
+ composable { backStackEntry ->
+ HomeScreen(
+ onOpenChat = { id -> navController.navigate(Screen.Chat(id)) },
+ onNewChat = { navController.navigate(Screen.NewChat) }
+ )
+ }
+ composable { backStackEntry ->
+ val chat: Screen.Chat = backStackEntry.toRoute()
+ ChatScreen(
+ id = chat.id,
+ onBack = { navController.popBackStack() },
+ )
+ }
+ composable { backStackEntry ->
+ NewChatScreen(
+ onBack = { navController.popBackStack() },
+ )
+ }
+ composable { backStackEntry ->
+ ScanScreen(
+ onBack = { navController.popBackStack() },
+ )
+ }
+ composable { backStackEntry ->
+ MyQrScreen(
+ onBack = { navController.popBackStack() },
+ )
+ }
+ composable { backStackEntry ->
+ RelayScreen(
+ onBack = { navController.popBackStack() },
+ )
}
}
}
}
-}
\ No newline at end of file
+}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt
index 9b64f44..f4514e1 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt
@@ -1,25 +1,27 @@
package su.reya.coop
+import android.content.Intent
+import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.tooling.preview.Preview
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
+ val intent = Intent(this, NostrForegroundService::class.java)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ startForegroundService(intent)
+ } else {
+ startService(intent)
+ }
+
setContent {
App()
}
}
}
-
-@Preview
-@Composable
-fun AppAndroidPreview() {
- App()
-}
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt
new file mode 100644
index 0000000..1b4ddb4
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt
@@ -0,0 +1,32 @@
+package su.reya.coop
+
+import kotlinx.serialization.Serializable
+
+sealed interface Screen {
+ @Serializable
+ data object Home : Screen
+
+ @Serializable
+ data class Chat(val id: Long) : Screen
+
+ @Serializable
+ data object NewChat : Screen
+
+ @Serializable
+ data object Onboarding : Screen
+
+ @Serializable
+ data object Import : Screen
+
+ @Serializable
+ data object NewIdentity : Screen
+
+ @Serializable
+ data object Scan : Screen
+
+ @Serializable
+ data object MyQr : Screen
+
+ @Serializable
+ data object Relay : Screen
+}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt
new file mode 100644
index 0000000..4d53f16
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt
@@ -0,0 +1,93 @@
+package su.reya.coop
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+import androidx.annotation.RequiresApi
+import androidx.core.app.NotificationCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.ProcessLifecycleOwner
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import java.io.File
+
+class NostrForegroundService : Service() {
+ private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val nostr = NostrManager.instance
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun isUserInApp(): Boolean {
+ return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ createNotificationChannel()
+ val notification = createNotification("Connecting to Nostr...")
+ startForeground(1, notification)
+
+ serviceScope.launch {
+ try {
+ val dbDir = File(filesDir, "nostr")
+ dbDir.mkdirs()
+ // Initialize Nostr client
+ nostr.init(dbDir.absolutePath)
+ // Connect to bootstrap relays
+ nostr.connectBootstrapRelays()
+ // Handle notifications
+ nostr.handleLiteNotifications { event ->
+ if (!isUserInApp()) {
+ showNewMessageNotification(event.content())
+ }
+ }
+ } catch (e: Exception) {
+ println("Failed to start Nostr in background: ${e.message}")
+ }
+ }
+
+ return START_STICKY
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun createNotificationChannel() {
+ val channel = NotificationChannel(
+ "nostr_service",
+ "Nostr Background Service",
+ NotificationManager.IMPORTANCE_HIGH
+ )
+ val manager = getSystemService(NotificationManager::class.java)
+ manager?.createNotificationChannel(channel)
+ }
+
+ private fun createNotification(content: String): Notification {
+ return NotificationCompat.Builder(this, "nostr_service")
+ .setContentTitle("Coop")
+ .setContentText(content)
+ .setSmallIcon(android.R.drawable.ic_menu_send)
+ .setOngoing(true)
+ .build()
+ }
+
+ private fun showNewMessageNotification(message: String) {
+ val notification = NotificationCompat.Builder(this, "nostr_service")
+ .setContentTitle("New Message")
+ .setContentText(message)
+ .setAutoCancel(true)
+ .build()
+ val manager = getSystemService(NotificationManager::class.java)
+ manager?.notify(System.currentTimeMillis().toInt(), notification)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ serviceScope.cancel()
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt
new file mode 100644
index 0000000..b016fd3
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt
@@ -0,0 +1,349 @@
+package su.reya.coop.screens
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+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.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.LoadingIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+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.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+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_send
+import kotlinx.coroutines.flow.first
+import org.jetbrains.compose.resources.painterResource
+import rust.nostr.sdk.UnsignedEvent
+import su.reya.coop.LocalNostrViewModel
+import su.reya.coop.LocalSnackbarHostState
+import su.reya.coop.formatAsGroupHeader
+import su.reya.coop.roomId
+import su.reya.coop.shared.Avatar
+import su.reya.coop.shared.displayNameFlow
+import su.reya.coop.shared.pictureFlow
+import su.reya.coop.short
+
+@Composable
+fun ChatScreen(
+ id: Long,
+ onBack: () -> Unit,
+) {
+ val snackbarHostState = LocalSnackbarHostState.current
+ val viewModel = LocalNostrViewModel.current
+
+ val room = viewModel.getChatRoom(id)
+ val listState = rememberLazyListState()
+
+ val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
+ val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
+
+ var text by remember { mutableStateOf("") }
+ var loading by remember { mutableStateOf(true) }
+
+ val messages = remember { mutableStateListOf() }
+ val groupedMessages = remember(messages.toList()) {
+ messages.groupBy { it.createdAt().formatAsGroupHeader() }
+ }
+
+ fun setLoading(value: Boolean) {
+ loading = value
+ }
+
+ LaunchedEffect(id) {
+ // Start loading spinner
+ setLoading(true)
+
+ // Get messages
+ val initialMessages = viewModel.getChatRoomMessages(id)
+ messages.clear()
+ messages.addAll(initialMessages)
+
+ // Get msg relays for each member
+ val results = viewModel.chatRoomConnect(id)
+ results.forEach { (member, relays) ->
+ if (relays.isNotEmpty()) {
+ val metadata = viewModel.getMetadata(member).first { it != null }
+ val profile = metadata?.asRecord()
+ val name = profile?.displayName ?: profile?.name ?: member.short()
+
+ snackbarHostState.showSnackbar("Connected to messaging relays for $name")
+ }
+ }
+
+ // Stop loading spinner
+ setLoading(false)
+
+ // Handle new messages
+ viewModel.newEvents.collect { event ->
+ if (event.roomId() == id) {
+ if (event.id() !in messages.map { it.id() }) {
+ messages.add(0, event)
+ }
+ }
+ }
+ }
+
+ LaunchedEffect(messages.size) {
+ if (messages.isNotEmpty()) {
+ listState.animateScrollToItem(0)
+ }
+ }
+
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ topBar = {
+ TopAppBar(
+ title = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (loading) {
+ LoadingIndicator(
+ modifier = Modifier.size(32.dp),
+ )
+ } else {
+ Avatar(
+ picture = picture,
+ description = displayName,
+ size = 32.dp,
+ )
+ }
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(
+ text = displayName,
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ )
+ }
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_arrow_back),
+ contentDescription = "Back"
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ )
+ )
+ },
+ content = { innerPadding ->
+ Surface(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(top = innerPadding.calculateTopPadding()),
+ color = MaterialTheme.colorScheme.surface,
+ shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(bottom = innerPadding.calculateBottomPadding())
+ ) {
+ if (messages.isNotEmpty()) {
+ LazyColumn(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ contentPadding = PaddingValues(16.dp),
+ reverseLayout = true,
+ state = listState,
+ ) {
+ groupedMessages.forEach { (dateHeader, messagesInGroup) ->
+ items(
+ messagesInGroup,
+ key = { it.id()?.toBech32()!! }) { event ->
+ ChatMessage(event)
+ }
+ item {
+ DateSeparator(dateHeader)
+ }
+ }
+ }
+ } else {
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = "No messages yet",
+ style = MaterialTheme.typography.titleLargeEmphasized,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = "Your conversations will appear here.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+ }
+ ChatInput(
+ value = text,
+ onValueChange = { text = it },
+ onSend = {
+ viewModel.sendMessage(id, text)
+ text = ""
+ }
+ )
+ }
+ }
+ }
+ )
+}
+
+@Composable
+fun DateSeparator(date: String) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = date,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+}
+
+@Composable
+fun ChatMessage(
+ rumor: UnsignedEvent
+) {
+ val viewModel = LocalNostrViewModel.current
+ val currentUser = viewModel.currentUser()
+ val isMine = rumor.author() == currentUser
+
+ val bubbleShape = if (isMine) {
+ RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp)
+ } else {
+ RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 4.dp, bottomEnd = 20.dp)
+ }
+
+ val containerColor =
+ if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.tertiaryContainer
+
+ val contentColor =
+ if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onTertiaryContainer
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
+ ) {
+ Column(
+ horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
+ ) {
+ Surface(
+ color = containerColor,
+ contentColor = contentColor,
+ shape = bubbleShape,
+ modifier = Modifier
+ .widthIn(max = 280.dp)
+ .clickable(
+ onClick = {
+ val id = rumor.id()
+ if (id != null) {
+ val sent = viewModel.isMessageSent(id)
+ println("Sent: $sent")
+ }
+ }
+ )
+ ) {
+ Text(
+ text = rumor.content(),
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun ChatInput(
+ value: String,
+ onValueChange: (String) -> Unit,
+ onSend: () -> Unit
+) {
+
+ Surface(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .height(IntrinsicSize.Min),
+ verticalAlignment = Alignment.Bottom
+ ) {
+ TextField(
+ value = value,
+ onValueChange = onValueChange,
+ placeholder = { Text("Message") },
+ shape = RoundedCornerShape(28.dp),
+ colors = TextFieldDefaults.colors(
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent
+ ),
+ modifier = Modifier.weight(1f)
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ FilledTonalIconButton(
+ onClick = onSend,
+ modifier = Modifier
+ .fillMaxHeight()
+ .aspectRatio(1f),
+ colors = IconButtonDefaults.filledTonalIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ ) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_send),
+ contentDescription = "Send"
+ )
+ }
+ }
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt
new file mode 100644
index 0000000..c6ec696
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt
@@ -0,0 +1,441 @@
+package su.reya.coop.screens
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+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.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.LoadingIndicator
+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.pulltorefresh.PullToRefreshBox
+import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
+import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.material3.rememberTooltipState
+import androidx.compose.material3.toShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalClipboard
+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_new_chat
+import coop.composeapp.generated.resources.ic_qr
+import coop.composeapp.generated.resources.ic_scanner
+import kotlinx.coroutines.launch
+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.Room
+import su.reya.coop.Screen
+import su.reya.coop.ago
+import su.reya.coop.shared.Avatar
+import su.reya.coop.shared.displayNameFlow
+import su.reya.coop.shared.pictureFlow
+import su.reya.coop.short
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
+@Composable
+fun HomeScreen(
+ onOpenChat: (Long) -> Unit,
+ onNewChat: () -> Unit,
+) {
+ val clipboard = LocalClipboard.current
+ val navController = LocalNavController.current
+ val snackbarHostState = LocalSnackbarHostState.current
+ val viewModel = LocalNostrViewModel.current
+
+ val currentUser = viewModel.currentUser() ?: return
+ val currentUserProfile = viewModel.getMetadata(currentUser) ?: return
+
+ val userProfile by currentUserProfile.collectAsState(initial = null)
+ val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList())
+ val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
+
+ val scope = rememberCoroutineScope()
+ val sheetState = rememberModalBottomSheetState()
+ val listState = rememberLazyListState()
+ val pullToRefreshState = rememberPullToRefreshState()
+ val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
+ var showBottomSheet by remember { mutableStateOf(false) }
+ var isRefreshing by remember { mutableStateOf(false) }
+
+ val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
+ val qrResult by savedStateHandle
+ ?.getStateFlow("qr_result", null)
+ ?.collectAsState()
+ ?: remember { mutableStateOf(null) }
+
+ LaunchedEffect(Unit) {
+ viewModel.getChatRooms()
+ }
+
+ LaunchedEffect(qrResult) {
+ qrResult?.let { result ->
+ runCatching { PublicKey.parse(result) }
+ .onSuccess { pubkey ->
+ val roomId = viewModel.createChatRoom(listOf(pubkey))
+ navController.navigate(Screen.Chat(roomId))
+ }
+ .onFailure { e -> println("Failed to parse QR: ${e.message}") }
+
+ // Clear the nav state
+ navController.currentBackStackEntry?.savedStateHandle?.remove("qr_result")
+ }
+ }
+
+ Scaffold(
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ topBar = {
+ TopAppBar(
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ ),
+ title = {
+ Text(
+ text = "Coop",
+ style = MaterialTheme.typography.titleMediumEmphasized
+ )
+ },
+ actions = {
+ // QR Scanner
+ IconButton(onClick = { navController.navigate(Screen.Scan) }) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_scanner),
+ contentDescription = "Scanner"
+ )
+ }
+ // User
+ IconButton(onClick = { showBottomSheet = true }) {
+ Avatar(
+ picture = userProfile?.asRecord()?.picture,
+ description = userProfile?.asRecord()?.displayName,
+ size = 32.dp,
+ )
+ }
+ }
+ )
+ },
+ 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
+ .fillMaxSize()
+ .padding(top = innerPadding.calculateTopPadding()),
+ color = MaterialTheme.colorScheme.surface,
+ shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
+ ) {
+ PullToRefreshBox(
+ modifier = Modifier.fillMaxSize(),
+ isRefreshing = isRefreshing,
+ state = pullToRefreshState,
+ onRefresh = {
+ scope.launch {
+ isRefreshing = true
+ viewModel.refreshChatRooms()
+ isRefreshing = false
+ }
+ },
+ indicator = {
+ PullToRefreshDefaults.LoadingIndicator(
+ state = pullToRefreshState,
+ isRefreshing = isRefreshing,
+ modifier = Modifier.align(Alignment.TopCenter),
+ )
+ }
+ ) {
+ if (!isPartialProcessedGiftWrap) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ LoadingIndicator()
+ }
+ } else if (chatRooms.isEmpty()) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = "No chats yet",
+ style = MaterialTheme.typography.titleLargeEmphasized.copy(
+ fontWeight = FontWeight.SemiBold
+ ),
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = "Your conversations will appear here.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+ } else {
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ items(chatRooms.toList(), key = { it.id }) { room ->
+ ChatRoom(
+ room = room,
+ onClick = { onOpenChat(room.id) }
+ )
+ }
+ }
+ }
+ }
+
+ if (showBottomSheet) {
+ ModalBottomSheet(
+ onDismissRequest = { showBottomSheet = false },
+ sheetState = sheetState,
+ ) {
+ val pubkey = viewModel.currentUser()
+ val shortPubkey = pubkey?.short() ?: "Not available"
+
+ val userName =
+ userProfile?.asRecord()?.displayName
+ ?: userProfile?.asRecord()?.name
+ ?: "No name"
+
+ val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
+ scope.launch {
+ sheetState.hide()
+ showBottomSheet = false
+ action()
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxWidth(),
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(84.dp)
+ .clip(MaterialShapes.Cookie9Sided.toShape()),
+ contentAlignment = Alignment.Center
+ ) {
+ Avatar(
+ picture = userProfile?.asRecord()?.picture,
+ description = userProfile?.asRecord()?.displayName,
+ shape = MaterialShapes.Cookie9Sided.toShape(),
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ Spacer(modifier = Modifier.size(8.dp))
+ Box(
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = userName,
+ style = MaterialTheme.typography.titleLargeEmphasized,
+ )
+ }
+ Spacer(modifier = Modifier.size(8.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ OutlinedButton(
+ onClick = {
+ dismissAndRun { navController.navigate(Screen.MyQr) }
+ },
+ ) {
+ Text(text = shortPubkey)
+ }
+ FilledIconButton(
+ onClick = {
+ scope.launch {
+ sheetState.hide()
+ showBottomSheet = false
+ navController.navigate(Screen.MyQr)
+ }
+ },
+ shape = MaterialShapes.Square.toShape()
+ ) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_qr),
+ contentDescription = "My QR"
+ )
+ }
+ }
+ }
+ Spacer(modifier = Modifier.size(16.dp))
+ BottomMenuList(onDismiss = dismissAndRun)
+ }
+ }
+ }
+ }
+ },
+ )
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun ChatRoom(room: Room, onClick: () -> Unit) {
+ val viewModel = LocalNostrViewModel.current
+ val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
+ val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
+
+ ListItem(
+ modifier = Modifier.clickable(onClick = onClick),
+ leadingContent = {
+ Avatar(picture = picture, description = displayName)
+ },
+ headlineContent = {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = displayName,
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ modifier = Modifier.weight(1f)
+ )
+ Text(
+ text = room.createdAt.ago(),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ },
+ supportingContent = {
+ if (!room.lastMessage.isNullOrBlank()) {
+ Text(
+ text = room.lastMessage!!,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ )
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun BottomMenuList(
+ onDismiss: (suspend () -> Unit) -> Unit
+) {
+ val navController = LocalNavController.current
+ val viewModel = LocalNostrViewModel.current
+
+ val defaultMenuList = listOf(
+ "Relay Management" to { navController.navigate(Screen.Relay) },
+ "Spams & Blocks" to { },
+ "Contacts" to { },
+ "Settings" to { }
+ )
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
+ ) {
+ defaultMenuList.forEachIndexed { index, (title, action) ->
+ SegmentedListItem(
+ onClick = { onDismiss { action() } },
+ shapes = ListItemDefaults.segmentedShapes(
+ index = index,
+ count = defaultMenuList.size
+ ),
+ content = { Text(text = title) },
+ )
+ }
+ }
+ Spacer(modifier = Modifier.size(16.dp))
+ FilledTonalButton(
+ onClick = { viewModel.logout() },
+ colors = ButtonDefaults.filledTonalButtonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError
+ )
+ ) {
+ Text(text = "Logout")
+ }
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt
new file mode 100644
index 0000000..57f2c51
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt
@@ -0,0 +1,256 @@
+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.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LoadingIndicator
+import androidx.compose.material3.MaterialShapes
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+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.material3.toShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.style.TextAlign
+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_scanner
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.painterResource
+import rust.nostr.sdk.Keys
+import rust.nostr.sdk.NostrConnectUri
+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 ImportScreen(
+ isLoading: Boolean,
+ onBack: () -> Unit,
+ onSave: (secret: String) -> Unit
+) {
+ val snackbarHostState = LocalSnackbarHostState.current
+ val navController = LocalNavController.current
+ val viewModel = LocalNostrViewModel.current
+ val scope = rememberCoroutineScope()
+
+ var secret by remember { mutableStateOf("") }
+ var pubkey by remember { mutableStateOf(null) }
+ val metadata by remember(pubkey) {
+ if (pubkey != null) {
+ viewModel.getMetadata(pubkey!!)
+ } else {
+ MutableStateFlow(null)
+ }
+ }.collectAsState(null)
+
+
+ val profile = metadata?.asRecord()
+ val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
+ val picture = profile?.picture
+
+ val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
+ val qrResult by savedStateHandle
+ ?.getStateFlow("qr_result", null)
+ ?.collectAsState()
+ ?: remember { mutableStateOf(null) }
+
+ LaunchedEffect(qrResult) {
+ qrResult?.let { result ->
+ runCatching {
+ if (result.startsWith("nsec")) {
+ Keys.parse(result)
+ } else if (result.startsWith("bunker://")) {
+ NostrConnectUri.parse(result)
+ } else {
+ throw IllegalArgumentException("Invalid secret: $result")
+ }
+ }
+ .onSuccess { it -> secret = result }
+ .onFailure { e -> println("Failed to parse QR: ${e.message}") }
+ // Clear the nav state
+ navController.currentBackStackEntry?.savedStateHandle?.remove("qr_result")
+ }
+ }
+
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = "Import",
+ style = MaterialTheme.typography.titleMediumEmphasized
+ )
+ },
+ 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(),
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .padding(top = innerPadding.calculateTopPadding()),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Box(
+ modifier = Modifier
+ .size(120.dp)
+ .clip(MaterialShapes.Pentagon.toShape()),
+ contentAlignment = Alignment.Center
+ ) {
+ Avatar(
+ picture = picture,
+ description = "Profile picture",
+ modifier = Modifier.fillMaxSize(),
+ shape = MaterialShapes.Pentagon.toShape(),
+ )
+ }
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(
+ text = displayName,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.titleLargeEmphasized,
+ )
+ }
+ Surface(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surface,
+ shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = "Enter your Secret Key or Bunker URI:",
+ style = MaterialTheme.typography.titleMediumEmphasized.copy(
+ fontWeight = FontWeight.SemiBold,
+ ),
+ )
+ BasicTextField(
+ value = secret,
+ onValueChange = { secret = it },
+ modifier = Modifier.fillMaxWidth(),
+ maxLines = 4,
+ visualTransformation = PasswordVisualTransformation('*'),
+ textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
+ color = MaterialTheme.colorScheme.primaryFixed,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
+ decorationBox = { innerTextField ->
+ Box(contentAlignment = Alignment.CenterStart) {
+ if (secret.isEmpty()) {
+ Text(
+ "bunker://",
+ style = MaterialTheme.typography.bodyMediumEmphasized.copy(
+ fontWeight = FontWeight.SemiBold,
+ ),
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
+ alpha = 0.5f
+ )
+ )
+ }
+ innerTextField()
+ }
+ }
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ Button(
+ onClick = {
+ if (pubkey == null) {
+ scope.launch {
+ viewModel.verifyIdentity(secret).let { pubkey = it }
+ }
+ } else {
+ onSave(secret)
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(ButtonDefaults.MediumContainerHeight),
+ enabled = secret.isNotBlank() && !isLoading,
+ ) {
+ if (isLoading) {
+ LoadingIndicator()
+ } else {
+ Text(
+ text = if (pubkey == null) "Verify" else "Continue",
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ )
+}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt
new file mode 100644
index 0000000..e8cd521
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt
@@ -0,0 +1,68 @@
+package su.reya.coop.screens
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import coop.composeapp.generated.resources.Res
+import coop.composeapp.generated.resources.ic_arrow_back
+import io.github.alexzhirkevich.qrose.rememberQrCodePainter
+import org.jetbrains.compose.resources.painterResource
+import su.reya.coop.LocalNostrViewModel
+import su.reya.coop.LocalSnackbarHostState
+
+@Composable
+fun MyQrScreen(
+ onBack: () -> Unit
+) {
+ val snackbarHostState = LocalSnackbarHostState.current
+ val viewModel = LocalNostrViewModel.current
+ val currentUser = viewModel.currentUser() ?: return
+
+ Scaffold(
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = "My QR",
+ style = MaterialTheme.typography.titleMediumEmphasized
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_arrow_back),
+ contentDescription = "Back"
+ )
+ }
+ }
+ )
+ },
+ content = { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painter = rememberQrCodePainter(currentUser.toBech32()),
+ contentDescription = "My QR"
+ )
+ }
+ }
+ )
+}
\ No newline at end of file
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..e80ce27
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt
@@ -0,0 +1,406 @@
+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.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.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
+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.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
+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 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("") }
+
+ 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
+
+ if (query.startsWith("npub1")) {
+ val pubkey = try {
+ PublicKey.parse(query)
+ } catch (e: Exception) {
+ println("Failed to parse npub: ${e.message}")
+ 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 = ""
+ }
+ }
+
+ LaunchedEffect(qrResult) {
+ qrResult?.let { result ->
+ runCatching { PublicKey.parse(result) }
+ .onSuccess { pubkey -> selectedReceivers.add(pubkey) }
+ .onFailure { e -> println("Failed to parse QR: ${e.message}") }
+ // Clear the nav state
+ navController.currentBackStackEntry?.savedStateHandle?.remove("qr_result")
+ }
+ }
+
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = if (createGroup.value) "New group chat" else "New chat",
+ style = MaterialTheme.typography.titleMediumEmphasized
+ )
+ },
+ 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"
+ )
+ }
+ }
+ )
+ },
+ 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
+ .fillMaxSize()
+ .padding(innerPadding)
+ ) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ shape = RoundedCornerShape(24.dp),
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.Top,
+ ) {
+ Text(
+ text = "To:",
+ 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,
+ 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()
+ }
+ }
+ )
+ }
+ }
+ }
+ Spacer(modifier = Modifier.size(16.dp))
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 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))
+ }
+ }
+ }
+ }
+ )
+}
+
+@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
+
+ 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
+) {
+ if (items.isEmpty()) return
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
+ ) {
+ 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 = items.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/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt
new file mode 100644
index 0000000..654485b
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt
@@ -0,0 +1,245 @@
+package su.reya.coop.screens
+
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LoadingIndicator
+import androidx.compose.material3.MaterialShapes
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+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.material3.toShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import coop.composeapp.generated.resources.Res
+import coop.composeapp.generated.resources.ic_arrow_back
+import coop.composeapp.generated.resources.ic_plus
+import org.jetbrains.compose.resources.painterResource
+import su.reya.coop.LocalSnackbarHostState
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun NewIdentityScreen(
+ isLoading: Boolean,
+ onBack: () -> Unit,
+ onSave: (name: String, bio: String?, picture: Uri?) -> Unit
+) {
+ val snackbarHostState = LocalSnackbarHostState.current
+ var name by remember { mutableStateOf("") }
+ var bio by remember { mutableStateOf("") }
+ var picture by remember { mutableStateOf(null) }
+
+ val launcher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.GetContent()
+ ) { uri: Uri? ->
+ picture = uri
+ }
+
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = "Create a new identity",
+ style = MaterialTheme.typography.titleMediumEmphasized
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_arrow_back),
+ contentDescription = "Back"
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ )
+ )
+ },
+ content = { innerPadding ->
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .padding(top = innerPadding.calculateTopPadding()),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Box(
+ modifier = Modifier
+ .size(120.dp)
+ .clip(MaterialShapes.Pentagon.toShape())
+ .clickable { launcher.launch("image/*") },
+ contentAlignment = Alignment.Center
+ ) {
+ if (picture != null) {
+ AsyncImage(
+ model = picture,
+ contentDescription = "Profile picture",
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop
+ )
+ } else {
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ modifier = Modifier.fillMaxSize()
+
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_plus),
+ contentDescription = "Pick avatar",
+ modifier = Modifier.size(48.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+ }
+ Surface(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surface,
+ shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = "What others should call you?",
+ style = MaterialTheme.typography.titleLargeEmphasized.copy(
+ fontWeight = FontWeight.SemiBold,
+ ),
+ )
+ BasicTextField(
+ value = name,
+ onValueChange = { name = it },
+ modifier = Modifier.fillMaxWidth(),
+ maxLines = 1,
+ textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy(
+ color = MaterialTheme.colorScheme.primaryFixed,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
+ decorationBox = { innerTextField ->
+ Box(contentAlignment = Alignment.CenterStart) {
+ if (name.isEmpty()) {
+ Text(
+ "Alice",
+ style = MaterialTheme.typography.headlineLargeEmphasized.copy(
+ fontWeight = FontWeight.SemiBold,
+ ),
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
+ alpha = 0.5f
+ )
+ )
+ }
+ innerTextField()
+ }
+ }
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(
+ text = "Your bio (optional)",
+ style = MaterialTheme.typography.titleLargeEmphasized.copy(
+ fontWeight = FontWeight.SemiBold,
+ ),
+ )
+ BasicTextField(
+ value = bio,
+ onValueChange = { bio = it },
+ modifier = Modifier.fillMaxWidth(),
+ maxLines = 3,
+ textStyle = MaterialTheme.typography.bodyLarge.copy(
+ color = MaterialTheme.colorScheme.primaryFixed,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
+ decorationBox = { innerTextField ->
+ Box(contentAlignment = Alignment.CenterStart) {
+ if (bio.isEmpty()) {
+ Text(
+ "I love cat",
+ style = MaterialTheme.typography.headlineLargeEmphasized.copy(
+ fontWeight = FontWeight.SemiBold,
+ ),
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
+ alpha = 0.5f
+ )
+ )
+ }
+ innerTextField()
+ }
+ }
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ Button(
+ onClick = {
+ onSave(name, bio, picture)
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(ButtonDefaults.MediumContainerHeight),
+ enabled = name.isNotBlank() && !isLoading,
+ ) {
+ if (isLoading) {
+ LoadingIndicator()
+ } else {
+ Text(
+ text = "Continue",
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ )
+}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt
new file mode 100644
index 0000000..9f98964
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt
@@ -0,0 +1,159 @@
+package su.reya.coop.screens
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.drawscope.rotate
+import androidx.compose.ui.graphics.drawscope.translate
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.unit.dp
+import coop.composeapp.generated.resources.Res
+import coop.composeapp.generated.resources.coop
+import org.jetbrains.compose.resources.painterResource
+import su.reya.coop.LocalSnackbarHostState
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) {
+ val snackbarHostState = LocalSnackbarHostState.current
+ val logoPainter = painterResource(Res.drawable.coop)
+
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ content = { innerPadding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(bottom = innerPadding.calculateBottomPadding())
+ ) {
+ LogoRepeatingBackground(
+ painter = logoPainter,
+ logosPerRow = 6,
+ rotationDegrees = -25f,
+ horizontalOffset = 0.5f
+ )
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ Box(
+ modifier = Modifier
+ .weight(2f)
+ .fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ // TODO: Add headline
+ }
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .padding(bottom = innerPadding.calculateBottomPadding()),
+ contentAlignment = Alignment.BottomEnd,
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = innerPadding.calculateBottomPadding()),
+ ) {
+ Button(
+ onClick = onOpenNew,
+ modifier = Modifier
+ .fillMaxWidth()
+ .size(ButtonDefaults.LargeContainerHeight),
+ ) {
+ Text(
+ text = "Start messaging",
+ style = MaterialTheme.typography.titleLargeEmphasized,
+ )
+ }
+ Spacer(modifier = Modifier.size(16.dp))
+ FilledTonalButton(
+ onClick = onOpenImport,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(ButtonDefaults.LargeContainerHeight),
+ colors = ButtonDefaults.filledTonalButtonColors(
+ containerColor = MaterialTheme.colorScheme.tertiaryContainer,
+ contentColor = MaterialTheme.colorScheme.onTertiaryContainer
+ ),
+ ) {
+ Text(
+ text = "Import identity",
+ style = MaterialTheme.typography.titleLargeEmphasized,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ )
+}
+
+@Composable
+fun LogoRepeatingBackground(
+ painter: Painter,
+ logosPerRow: Int,
+ rotationDegrees: Float = 0f,
+ horizontalOffset: Float = 0.5f
+) {
+ val tintColor = MaterialTheme.colorScheme.primary
+
+ Canvas(modifier = Modifier.fillMaxSize()) {
+ val canvasWidth = size.width
+ val canvasHeight = size.height
+ val logoSize = canvasWidth / logosPerRow
+
+ val offsetX = logoSize * horizontalOffset
+ val extraPadding = 2
+
+ val cols = logosPerRow + (extraPadding * 2)
+ val rows = (canvasHeight / logoSize).toInt() + 1
+
+ for (row in 0 until rows) {
+ for (col in -extraPadding until cols) {
+ val px = (col * logoSize) - offsetX
+ val py = row * logoSize
+
+ rotate(
+ degrees = rotationDegrees,
+ pivot = Offset(
+ px + logoSize / 2,
+ py + logoSize / 2
+ )
+ ) {
+ translate(left = px, top = py) {
+ with(painter) {
+ draw(
+ size = Size(logoSize, logoSize),
+ alpha = 0.1f,
+ colorFilter = ColorFilter.tint(
+ tintColor
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt
new file mode 100644
index 0000000..4a25f8d
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt
@@ -0,0 +1,236 @@
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+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.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import coop.composeapp.generated.resources.Res
+import coop.composeapp.generated.resources.ic_arrow_back
+import org.jetbrains.compose.resources.painterResource
+import rust.nostr.sdk.RelayMetadata
+import rust.nostr.sdk.RelayUrl
+import su.reya.coop.LocalNostrViewModel
+import su.reya.coop.LocalSnackbarHostState
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun RelayScreen(
+ onBack: () -> Unit
+) {
+ val snackbarHostState = LocalSnackbarHostState.current
+ val viewModel = LocalNostrViewModel.current
+
+ val msgRelayList = remember { mutableStateListOf() }
+ val relayList = remember { mutableStateMapOf() }
+
+ val inboxRelays by remember {
+ derivedStateOf {
+ relayList.filter { it.value == RelayMetadata.READ || it.value == null }.keys.toList()
+ }
+ }
+
+ val outboxRelays by remember {
+ derivedStateOf {
+ relayList.filter { it.value == RelayMetadata.WRITE || it.value == null }.keys.toList()
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ relayList.putAll(viewModel.currentUserRelayList())
+ msgRelayList.addAll(viewModel.currentUserMsgRelayList())
+ }
+
+ Scaffold(
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ topBar = {
+ TopAppBar(
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ ),
+ title = {
+ Text(
+ text = "Relay Management",
+ style = MaterialTheme.typography.titleMediumEmphasized
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_arrow_back),
+ contentDescription = "Back"
+ )
+ }
+ }
+ )
+ },
+ content = { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = "Messaging Relay List",
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ )
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
+ ) {
+ if (msgRelayList.isNotEmpty()) {
+ msgRelayList.forEachIndexed { index, relayUrl ->
+ SegmentedListItem(
+ onClick = { },
+ shapes = ListItemDefaults.segmentedShapes(
+ index = index,
+ count = msgRelayList.size
+ ),
+ content = { Text(text = relayUrl.toString()) },
+ )
+ }
+ } else {
+ Surface(
+ color = MaterialTheme.colorScheme.surface,
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "No relays configured",
+ style = MaterialTheme.typography.labelMediumEmphasized,
+ )
+ }
+ }
+ }
+ }
+ }
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = "Inbox Relays",
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ )
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
+ ) {
+ if (inboxRelays.isNotEmpty()) {
+ inboxRelays.forEachIndexed { index, relayUrl ->
+ SegmentedListItem(
+ onClick = { },
+ shapes = ListItemDefaults.segmentedShapes(
+ index = index,
+ count = inboxRelays.size
+ ),
+ content = { Text(text = relayUrl.toString()) },
+ )
+ }
+ } else {
+ Surface(
+ color = MaterialTheme.colorScheme.surface,
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "No relays configured",
+ style = MaterialTheme.typography.labelMediumEmphasized,
+ )
+ }
+ }
+ }
+ }
+ }
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = "Outbox Relays",
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ )
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
+ ) {
+ if (outboxRelays.isNotEmpty()) {
+ outboxRelays.forEachIndexed { index, relayUrl ->
+ SegmentedListItem(
+ onClick = { },
+ shapes = ListItemDefaults.segmentedShapes(
+ index = index,
+ count = outboxRelays.size
+ ),
+ content = { Text(text = relayUrl.toString()) },
+ )
+ }
+ } else {
+ Surface(
+ color = MaterialTheme.colorScheme.surface,
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "No relays configured",
+ style = MaterialTheme.typography.labelMediumEmphasized,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ )
+}
\ No newline at end of file
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..b351b02
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt
@@ -0,0 +1,115 @@
+package su.reya.coop.screens
+
+import android.annotation.SuppressLint
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import coop.composeapp.generated.resources.Res
+import coop.composeapp.generated.resources.ic_arrow_back
+import org.jetbrains.compose.resources.painterResource
+import org.publicvalue.multiplatform.qrcode.CameraPosition
+import org.publicvalue.multiplatform.qrcode.CodeType
+import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions
+import su.reya.coop.LocalNavController
+import su.reya.coop.LocalSnackbarHostState
+
+@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
+@Composable
+fun ScanScreen(
+ onBack: () -> Unit
+) {
+ val navController = LocalNavController.current
+ val snackbarHostState = LocalSnackbarHostState.current
+
+ val onResult: (String) -> Unit = { result ->
+ navController.previousBackStackEntry?.savedStateHandle?.set("qr_result", result)
+ navController.popBackStack()
+ }
+
+ Scaffold(
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = "Scan QR",
+ style = MaterialTheme.typography.titleMediumEmphasized
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_arrow_back),
+ contentDescription = "Back"
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color.Transparent,
+ titleContentColor = Color.White,
+ navigationIconContentColor = Color.White,
+ )
+ )
+ },
+ ) { innerPadding ->
+ Box(modifier = Modifier.fillMaxSize()) {
+ ScannerWithPermissions(
+ modifier = Modifier.fillMaxSize(),
+ onScanned = {
+ println("Scanned: $it");
+ onResult(it)
+ true
+ },
+ types = listOf(CodeType.QR),
+ cameraPosition = CameraPosition.BACK,
+ enableTorch = false
+ )
+ Canvas(modifier = Modifier.fillMaxSize()) {
+ val scannerSize = 250.dp.toPx()
+ val left = (size.width - scannerSize) / 2
+ val top = (size.height - scannerSize) / 2
+ drawRect(color = Color.Black.copy(alpha = 0.6f))
+ drawRect(
+ color = Color.Transparent,
+ topLeft = Offset(left, top),
+ size = Size(scannerSize, scannerSize),
+ blendMode = BlendMode.Clear
+ )
+ }
+ Box(
+ modifier = Modifier
+ .size(250.dp)
+ .align(Alignment.Center)
+ .border(2.dp, Color.White, RoundedCornerShape(12.dp))
+ )
+ Text(
+ text = "Scan a Nostr address",
+ style = MaterialTheme.typography.titleSmallEmphasized,
+ color = Color.White,
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 64.dp)
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/Avatar.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/Avatar.kt
new file mode 100644
index 0000000..023f871
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/Avatar.kt
@@ -0,0 +1,38 @@
+package su.reya.coop.shared
+
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import coop.composeapp.generated.resources.Res
+import coop.composeapp.generated.resources.avatar
+import org.jetbrains.compose.resources.painterResource
+
+@Composable
+fun Avatar(
+ picture: String?,
+ description: String?,
+ modifier: Modifier = Modifier,
+ size: Dp = 48.dp,
+ shape: Shape = CircleShape
+) {
+ val placeholder = painterResource(Res.drawable.avatar)
+
+ AsyncImage(
+ model = picture,
+ contentDescription = description,
+ modifier = modifier
+ .size(size)
+ .clip(shape),
+ contentScale = ContentScale.Crop,
+ fallback = placeholder,
+ error = placeholder,
+ placeholder = placeholder
+ )
+}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt
new file mode 100644
index 0000000..5efe323
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt
@@ -0,0 +1,33 @@
+package su.reya.coop.shared
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import su.reya.coop.NostrViewModel
+import su.reya.coop.Room
+import su.reya.coop.short
+
+fun Room.displayNameFlow(viewModel: NostrViewModel): Flow {
+ if (!subject.isNullOrBlank()) return flowOf(subject!!)
+
+ val memberFlows = members.map { viewModel.getMetadata(it) }
+
+ return combine(memberFlows) { metadataArray ->
+ if (isGroup()) {
+ val profiles = metadataArray.map { it?.asRecord() }
+ val names = profiles.take(2).mapNotNull { it?.name ?: it?.displayName }
+ var combined = names.joinToString(", ")
+ if (profiles.size > 2) combined += ", +${profiles.size - 2}"
+ combined.ifBlank { "Unknown group" }
+ } else {
+ val profile = metadataArray.firstOrNull()?.asRecord()
+ profile?.name ?: profile?.displayName ?: members.firstOrNull()?.short() ?: "Unknown"
+ }
+ }
+}
+
+fun Room.pictureFlow(viewModel: NostrViewModel): Flow {
+ val firstMember = members.firstOrNull() ?: return kotlinx.coroutines.flow.flowOf(null)
+ return viewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
+}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretCrypto.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretCrypto.kt
new file mode 100644
index 0000000..dac0de6
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretCrypto.kt
@@ -0,0 +1,78 @@
+package su.reya.coop.coop.storage
+
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import java.nio.charset.StandardCharsets
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+data class SecretEntry(
+ val encrypted: String,
+ val iv: String
+)
+
+class SecretCrypto {
+ private val keyAlias = "coop"
+ private val keyStoreType = "AndroidKeyStore"
+ private val transformation = "AES/GCM/NoPadding"
+
+ fun encrypt(content: String): SecretEntry {
+ // Initialize cipher
+ val cipher = Cipher.getInstance(transformation)
+ cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
+
+ // Encrypt content
+ val encrypted = cipher.doFinal(content.toByteArray())
+ val iv = cipher.iv
+
+ return SecretEntry(
+ encrypted = Base64.encodeToString(encrypted, Base64.NO_WRAP),
+ iv = Base64.encodeToString(iv, Base64.NO_WRAP)
+ )
+ }
+
+ fun decrypt(entry: SecretEntry): String {
+ val encrypted = Base64.decode(entry.encrypted, Base64.NO_WRAP)
+ val iv = Base64.decode(entry.iv, Base64.NO_WRAP)
+
+ // Initialize cipher
+ val cipher = Cipher.getInstance(transformation)
+ val spec = GCMParameterSpec(128, iv)
+ cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
+
+ // Decrypt content
+ val plaintext = cipher.doFinal(encrypted)
+
+ return String(plaintext, StandardCharsets.UTF_8)
+ }
+
+ private fun getOrCreateKey(): SecretKey {
+ val keyStore = KeyStore.getInstance(keyStoreType).apply { load(null) }
+ val existingKey = keyStore.getKey(keyAlias, null)
+
+ // Return existing key if available
+ if (existingKey is SecretKey) return existingKey
+
+ // Construct a new key generator
+ val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStoreType)
+
+ // Initialize key generation parameters
+ val spec = KeyGenParameterSpec.Builder(
+ keyAlias,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .setKeySize(256)
+ .build()
+
+ // Generate a new key
+ keyGenerator.init(spec)
+
+ return keyGenerator.generateKey()
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretStore.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretStore.kt
new file mode 100644
index 0000000..37ffc47
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretStore.kt
@@ -0,0 +1,43 @@
+package su.reya.coop.coop.storage
+
+import android.content.Context
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.first
+import su.reya.coop.storage.SecretStorage
+
+private val Context.dataStore by preferencesDataStore("secret_store")
+
+class SecretStore(private val context: Context) : SecretStorage {
+ private val crypto = SecretCrypto()
+
+ override suspend fun set(key: String, value: String) {
+ val entry = crypto.encrypt(value)
+
+ context.dataStore.edit { prefs ->
+ prefs[stringPreferencesKey("${key}_encrypted")] = entry.encrypted
+ prefs[stringPreferencesKey("${key}_iv")] = entry.iv
+ }
+ }
+
+ override suspend fun get(key: String): String? {
+ val prefs = context.dataStore.data.first()
+ val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null
+ val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null
+
+ return crypto.decrypt(SecretEntry(encrypted, iv))
+ }
+
+ override suspend fun clear(key: String) {
+ context.dataStore.edit { prefs ->
+ prefs.remove(stringPreferencesKey("${key}_encrypted"))
+ prefs.remove(stringPreferencesKey("${key}_iv"))
+ }
+ }
+
+ override suspend fun has(key: String): Boolean {
+ val prefs = context.dataStore.data.first()
+ return prefs[stringPreferencesKey("${key}_encrypted")] != null
+ }
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index 6f8e6ea..1f172e1 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -9,4 +9,14 @@ org.gradle.caching=true
#Android
android.nonTransitiveRClass=true
-android.useAndroidX=true
\ No newline at end of file
+android.useAndroidX=true
+android.defaults.buildfeatures.resvalues=true
+android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
+android.enableAppCompileTimeRClass=false
+android.usesSdkInManifest.disallowed=false
+android.uniquePackageNames=false
+android.dependency.useConstraints=true
+android.r8.strictFullModeForKeepRules=false
+android.r8.optimizedResourceShrinking=false
+android.builtInKotlin=false
+android.newDsl=false
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 1fc7948..1a4e31f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,5 +1,5 @@
[versions]
-agp = "8.13.2"
+agp = "9.2.1"
android-compileSdk = "36"
android-minSdk = "24"
android-targetSdk = "36"
@@ -8,11 +8,14 @@ androidx-appcompat = "1.7.1"
androidx-core = "1.18.0"
androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0"
+androidx-navigation = "2.8.8"
androidx-testExt = "1.3.0"
composeMultiplatform = "1.10.3"
junit = "4.13.2"
kotlin = "2.3.20"
+kotlinx-serialization = "1.8.0"
material3 = "1.10.0-alpha05"
+ktor = "3.4.3"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
@@ -23,6 +26,8 @@ androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "an
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
+androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
+kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
@@ -32,6 +37,12 @@ compose-material3 = { module = "org.jetbrains.compose.material3:material3", vers
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
+ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
+ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
+ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
+ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
+ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
+ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index d4081da..c61a118 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts
index 34b4167..dd1d5c4 100644
--- a/shared/build.gradle.kts
+++ b/shared/build.gradle.kts
@@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
+ kotlin("plugin.serialization") version libs.versions.kotlin.get()
}
kotlin {
@@ -11,7 +12,7 @@ kotlin {
jvmTarget.set(JvmTarget.JVM_11)
}
}
-
+
listOf(
iosArm64(),
iosSimulatorArm64()
@@ -21,10 +22,24 @@ kotlin {
isStatic = true
}
}
-
+
sourceSets {
commonMain.dependencies {
- // put your Multiplatform dependencies here
+ implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
+ implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
+ implementation("su.reya:nostr-sdk-kmp:0.2.3")
+ implementation("com.squareup.okio:okio:3.16.2")
+ implementation(libs.ktor.client.core)
+ implementation(libs.ktor.client.websockets)
+ implementation(libs.ktor.client.content.negotiation)
+ implementation(libs.ktor.serialization.kotlinx.json)
+ }
+ androidMain.dependencies {
+ implementation(libs.ktor.client.okhttp)
+ }
+ iosMain.dependencies {
+ implementation(libs.ktor.client.darwin)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
diff --git a/shared/src/androidMain/kotlin/su/reya/coop/Platform.android.kt b/shared/src/androidMain/kotlin/su/reya/coop/Platform.android.kt
deleted file mode 100644
index ee2a4fe..0000000
--- a/shared/src/androidMain/kotlin/su/reya/coop/Platform.android.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package su.reya.coop
-
-import android.os.Build
-
-class AndroidPlatform : Platform {
- override val name: String = "Android ${Build.VERSION.SDK_INT}"
-}
-
-actual fun getPlatform(): Platform = AndroidPlatform()
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/CoopWebSocket.kt b/shared/src/commonMain/kotlin/su/reya/coop/CoopWebSocket.kt
new file mode 100644
index 0000000..ff2a658
--- /dev/null
+++ b/shared/src/commonMain/kotlin/su/reya/coop/CoopWebSocket.kt
@@ -0,0 +1,75 @@
+package su.reya.coop
+
+import io.ktor.client.HttpClient
+import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
+import io.ktor.client.plugins.websocket.webSocketSession
+import io.ktor.client.request.url
+import io.ktor.websocket.Frame
+import io.ktor.websocket.close
+import io.ktor.websocket.readBytes
+import io.ktor.websocket.readText
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.ClosedReceiveChannelException
+import rust.nostr.sdk.ConnectionMode
+import rust.nostr.sdk.CustomWebSocketTransport
+import rust.nostr.sdk.WebSocketAdapter
+import rust.nostr.sdk.WebSocketAdapterWrapper
+import rust.nostr.sdk.WebSocketMessage
+
+class KtorWebSocketAdapter(
+ private val client: HttpClient,
+ private val session: DefaultClientWebSocketSession
+) : WebSocketAdapter {
+
+ override suspend fun send(msg: WebSocketMessage) {
+ try {
+ when (msg) {
+ is WebSocketMessage.Text -> session.send(Frame.Text(msg.text))
+ is WebSocketMessage.Binary -> session.send(Frame.Binary(true, msg.bytes))
+ is WebSocketMessage.Ping -> session.send(Frame.Ping(msg.bytes))
+ is WebSocketMessage.Pong -> session.send(Frame.Pong(msg.bytes))
+ else -> {}
+ }
+ } catch (e: Exception) {
+ println("Attempted to send on a closed WebSocket: ${e.message}")
+ throw e
+ }
+ }
+
+ override suspend fun recv(): WebSocketMessage? {
+ return try {
+ when (val frame = session.incoming.receive()) {
+ is Frame.Text -> WebSocketMessage.Text(frame.readText())
+ is Frame.Binary -> WebSocketMessage.Binary(frame.readBytes())
+ is Frame.Ping -> WebSocketMessage.Ping(frame.readBytes())
+ is Frame.Pong -> WebSocketMessage.Pong(frame.readBytes())
+ else -> null
+ }
+ } catch (e: ClosedReceiveChannelException) {
+ null
+ } catch (e: Exception) {
+ throw e
+ }
+ }
+
+ override suspend fun closeConnection() {
+ session.cancel()
+ session.close()
+ }
+}
+
+class CoopWebSocketClient(private val httpClient: HttpClient) : CustomWebSocketTransport {
+ override fun supportPing(): Boolean = false
+
+ override suspend fun connect(url: String, mode: ConnectionMode): WebSocketAdapterWrapper {
+ try {
+ val session = httpClient.webSocketSession {
+ url(url)
+ }
+ val adapter = KtorWebSocketAdapter(httpClient, session)
+ return WebSocketAdapterWrapper(adapter)
+ } catch (e: Exception) {
+ throw e
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Greeting.kt b/shared/src/commonMain/kotlin/su/reya/coop/Greeting.kt
deleted file mode 100644
index de9e7c4..0000000
--- a/shared/src/commonMain/kotlin/su/reya/coop/Greeting.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package su.reya.coop
-
-class Greeting {
- private val platform = getPlatform()
-
- fun greet(): String {
- return "Hello, ${platform.name}!"
- }
-}
\ 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
new file mode 100644
index 0000000..54b5a70
--- /dev/null
+++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt
@@ -0,0 +1,870 @@
+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
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import rust.nostr.sdk.AckPolicy
+import rust.nostr.sdk.Alphabet
+import rust.nostr.sdk.AsyncNostrSigner
+import rust.nostr.sdk.Client
+import rust.nostr.sdk.ClientBuilder
+import rust.nostr.sdk.ClientNotification
+import rust.nostr.sdk.Contact
+import rust.nostr.sdk.Event
+import rust.nostr.sdk.EventBuilder
+import rust.nostr.sdk.EventId
+import rust.nostr.sdk.Filter
+import rust.nostr.sdk.GossipConfig
+import rust.nostr.sdk.Keys
+import rust.nostr.sdk.Kind
+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
+import rust.nostr.sdk.RelayCapabilities
+import rust.nostr.sdk.RelayMessageEnum
+import rust.nostr.sdk.RelayMetadata
+import rust.nostr.sdk.RelayUrl
+import rust.nostr.sdk.ReqExitPolicy
+import rust.nostr.sdk.ReqTarget
+import rust.nostr.sdk.SendEventTarget
+import rust.nostr.sdk.SingleLetterTag
+import rust.nostr.sdk.SleepWhenIdle
+import rust.nostr.sdk.SubscribeAutoCloseOptions
+import rust.nostr.sdk.Tag
+import rust.nostr.sdk.TagKind
+import rust.nostr.sdk.Timestamp
+import rust.nostr.sdk.UnsignedEvent
+import rust.nostr.sdk.UnwrappedGift
+import rust.nostr.sdk.extractRelayList
+import rust.nostr.sdk.giftWrapAsync
+import rust.nostr.sdk.initLogger
+import rust.nostr.sdk.nip17ExtractRelayList
+import kotlin.time.Duration
+
+object NostrManager {
+ val instance = Nostr()
+}
+
+class Nostr {
+ private val _isInitialized = MutableStateFlow(false)
+ val isInitialized: StateFlow = _isInitialized.asStateFlow()
+
+ var client: Client? = null
+ private set
+ var signer: UniversalSigner = UniversalSigner(Keys.generate())
+ private set
+ var deviceSigner: AsyncNostrSigner? = null
+ private set
+ var sentEvents: MutableMap> = mutableMapOf()
+ private set
+ var rumorMap: MutableMap = mutableMapOf()
+ private set
+
+ suspend fun init(dbPath: String) {
+ try {
+ if (_isInitialized.value) return
+
+ // Initialize the logger for nostr client
+ initLogger(LogLevel.DEBUG)
+
+ val lmdb = NostrDatabase.lmdb(dbPath)
+ val gossip = NostrGossip.inMemory()
+ val idleTimeout = Duration.parse("5m")
+ val httpClient = HttpClient {
+ install(WebSockets)
+ }
+
+ client =
+ ClientBuilder()
+ .signer(signer)
+ .websocketTransport(CoopWebSocketClient(httpClient))
+ .database(lmdb)
+ .gossip(gossip)
+ .gossipConfig(
+ GossipConfig()
+ .noBackgroundRefresh()
+ .fetchTimeout(Duration.parse("2s"))
+ .syncIdleTimeout(Duration.parse("100ms"))
+ .syncInitialTimeout(Duration.parse("100ms"))
+ )
+ .verifySubscriptions(false)
+ .automaticAuthentication(true)
+ .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
+ .build()
+
+ _isInitialized.value = true
+ } catch (e: Exception) {
+ throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e)
+ }
+ }
+
+ suspend fun waitUntilInitialized() {
+ _isInitialized.first { it }
+ }
+
+ suspend fun connectBootstrapRelays() {
+ // 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"))
+
+
+ // Indexer relay for NIP-65 discovery
+ client?.addRelay(
+ url = RelayUrl.parse("wss://indexer.coracle.social"),
+ capabilities = RelayCapabilities.gossip()
+ )
+
+ // Connect to all bootstrap relays and wait for all connections to be established
+ client?.connect(Duration.parse("2s"))
+ }
+
+ suspend fun disconnect() {
+ client?.shutdown()
+ }
+
+ suspend fun exit() {
+ signer.switch(Keys.generate())
+ deviceSigner = null
+ }
+
+ suspend fun setSigner(new: AsyncNostrSigner) {
+ try {
+ signer.switch(new)
+ // Fetch metadata for current user
+ getUserMetadata()
+ } catch (e: Exception) {
+ throw IllegalStateException("Failed to set signer: ${e.message}", e)
+ }
+ }
+
+ fun isSignedByUser(event: Event): Boolean {
+ return try {
+ signer.currentUser == event.author()
+ } catch (e: Exception) {
+ println("Failed to check if event is signed by user: ${e.message}")
+ false
+ }
+ }
+
+ suspend fun getUserMetadata() {
+ try {
+ val author = signer.currentUser ?: throw IllegalStateException("User not signed in")
+
+ // Get the latest metadata event
+ val metadataFilter =
+ Filter().kind(Kind.fromStd(KindStandard.METADATA)).author(author).limit(1u)
+
+ // Get the latest contact list event
+ val contactFilter =
+ Filter().kind(Kind.fromStd(KindStandard.CONTACT_LIST)).author(author).limit(1u)
+
+ // Get the latest messaging relay list event
+ val msgRelayFilter =
+ Filter().kind(Kind.fromStd(KindStandard.INBOX_RELAYS)).author(author).limit(1u)
+
+ // Construct a target that includes all filters
+ val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter))
+ val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
+
+ client?.subscribe(target = target, id = "user-metadata", closeOn = opts)
+ } catch (e: Exception) {
+ throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e)
+ }
+ }
+
+ suspend fun getUserMessages(msgRelayList: Event) {
+ try {
+ val author = signer.currentUser ?: throw IllegalStateException("User not signed in")
+ val relays = nip17ExtractRelayList(msgRelayList)
+
+ // Ensure relay connections
+ relays.forEach { relay ->
+ client?.addRelay(relay)
+ client?.connectRelay(relay)
+ }
+
+ // Construct a filter for gift wrap events
+ val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(author)
+ val target = mutableMapOf>()
+ relays.forEach { relay ->
+ target[relay] = listOf(filter)
+ }
+
+ client?.subscribe(
+ target = ReqTarget.manual(target),
+ id = "all-gift-wraps"
+ )
+ } catch (e: Exception) {
+ throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
+ }
+ }
+
+ suspend fun handleLiteNotifications(
+ onNewMessage: (UnsignedEvent) -> Unit,
+ ) {
+ val now = Timestamp.now()
+ val processedEvent = mutableSetOf()
+ val notifications = client?.notifications() ?: return
+
+ while (true) {
+ val notification = notifications.next() ?: continue
+
+ when (notification) {
+ is ClientNotification.Message -> {
+ val relayUrl = notification.relayUrl
+
+ when (val message = notification.message.asEnum()) {
+ is RelayMessageEnum.EventMsg -> {
+ val event = message.event
+ val subscriptionId = message.subscriptionId
+
+ // Ignore events not from the newest gift wraps subscription
+ if (subscriptionId != "newest-gift-wraps") continue
+
+ // Prevent processing duplicate events
+ if (processedEvent.contains(event.id())) continue
+ processedEvent.add(event.id())
+
+ if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
+ try {
+ val rumor = extractRumor(event)
+
+ // Handle new message
+ rumor?.createdAt()?.asSecs()?.let {
+ if (it >= now.asSecs()) {
+ onNewMessage(rumor)
+ }
+ }
+ } catch (e: Exception) {
+ println("Failed to extract rumor: $e")
+ }
+ }
+ }
+
+ else -> {
+ /* Ignore other event kinds */
+ }
+ }
+ }
+
+ else -> {
+ /* Ignore other message types */
+ }
+ }
+ }
+ }
+
+ suspend fun handleNotifications(
+ onMetadataUpdate: (PublicKey, Metadata) -> Unit,
+ onContactListUpdate: (List) -> Unit,
+ onNewMessage: (UnsignedEvent) -> Unit,
+ onSubscriptionClose: () -> Unit,
+ ) = coroutineScope {
+ val now = Timestamp.now()
+ val processedEvent = mutableSetOf()
+ val notifications = client?.notifications() ?: return@coroutineScope
+
+ var eoseTrackerJob: Job? = null
+
+ while (true) {
+ val notification = notifications.next() ?: continue
+
+ when (notification) {
+ is ClientNotification.Message -> {
+ val relayUrl = notification.relayUrl
+
+ when (val message = notification.message.asEnum()) {
+ is RelayMessageEnum.EventMsg -> {
+ val event = message.event
+ val id = message.subscriptionId
+
+ // Prevent processing duplicate events
+ if (processedEvent.contains(event.id())) continue
+ processedEvent.add(event.id())
+
+ if (event.kind().asStd()?.equals(KindStandard.METADATA) == true) {
+ try {
+ val metadata = Metadata.fromJson(event.content())
+ onMetadataUpdate(event.author(), metadata)
+ } catch (e: Exception) {
+ println("Failed to parse metadata: $e")
+ }
+ }
+
+ 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) {
+ // Get all gift wrap events for the current user
+ if (isSignedByUser(event = event)) {
+ getUserMessages(msgRelayList = event)
+ }
+ }
+
+ if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
+ try {
+ val rumor = extractRumor(event)
+
+ // Logic to notify UI after processing
+ // Cancel previous tracker if it exists
+ eoseTrackerJob?.cancel()
+ // Start a new tracker
+ eoseTrackerJob = launch {
+ delay(10000) // Wait for 10 seconds
+ onSubscriptionClose()
+ }
+
+ // Handle new message
+ rumor?.createdAt()?.asSecs()?.let {
+ if (it >= now.asSecs()) {
+ onNewMessage(rumor)
+ }
+ }
+ } catch (e: Exception) {
+ println("Failed to extract rumor: $e")
+ }
+ }
+ }
+
+ is RelayMessageEnum.EndOfStoredEvents -> {
+ val subscriptionId = message.subscriptionId
+
+ if (subscriptionId == "all-gift-wraps" || subscriptionId == "newest-gift-wraps") {
+ onSubscriptionClose()
+ }
+ }
+
+ is RelayMessageEnum.Ok -> {
+ if (sentEvents.containsKey(message.eventId)) {
+ val currentRelays = sentEvents[message.eventId] ?: emptyList()
+ sentEvents[message.eventId] = currentRelays + relayUrl
+ }
+ }
+
+ else -> {
+ /* Ignore other message types */
+ }
+ }
+ }
+
+ is ClientNotification.Shutdown -> {
+ break
+ }
+
+ else -> {
+ /* Ignore other message types */
+ }
+ }
+ }
+ }
+
+ private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
+ try {
+ val filter = Filter().identifier(giftId.toHex())
+ val event = client?.database()?.query(filter)?.first()
+
+ return event?.content()?.let { UnsignedEvent.fromJson(it) }
+ } catch (e: Exception) {
+ throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
+ }
+ }
+
+ private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
+ try {
+ val currentUser =
+ signer.currentUser ?: throw IllegalStateException("User not signed in")
+
+ // Ensure the rumor ID is set
+ val rumor = rumor.ensureId()
+ val roomId = rumor.roomId()
+
+ // Construct reference tags
+ val tags = listOf(
+ Tag.identifier(giftId.toHex()),
+ Tag.event(rumor.id()!!),
+ Tag.reference(roomId.toString()),
+ Tag.custom(TagKind.Unknown("k"), listOf("dm"))
+ )
+
+ // Set event kind
+ val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA);
+
+ val event = EventBuilder(kind, rumor.asJson())
+ .tags(tags)
+ .build(currentUser)
+ .signWithKeys(Keys.generate())
+
+ client?.database()?.saveEvent(event)
+ } catch (e: Exception) {
+ println("Failed to set cached rumor: ${e.message}")
+ }
+ }
+
+ private suspend fun extractRumor(event: Event): UnsignedEvent? {
+ // Check if the rumor is already cached
+ val cachedRumor = getCachedRumor(event.id())
+ if (cachedRumor != null) return cachedRumor
+
+ // Get all signers
+ val signers = listOfNotNull(signer, deviceSigner)
+ if (signers.isEmpty()) return null
+
+ // Try to unwrap the gift with each signer
+ for (signer in signers) {
+ try {
+ // TODO: custom unwrapping logic
+ val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event)
+ val rumor = gift.rumor()
+ // Save the rumor to the database
+ setCachedRumor(event.id(), rumor)
+ // Return the rumor
+ return rumor
+ } catch (e: Exception) {
+ println("Failed to unwrap gift: ${e.message}")
+ continue
+ }
+ }
+
+ return null
+ }
+
+ private suspend fun getDefaultRelayList(): Map {
+ // Construct a list of relays
+ val relayList = mapOf(
+ RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ,
+ RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ,
+ RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE,
+ RelayUrl.parse("wss://nostr.superfriends.online") to RelayMetadata.WRITE
+ )
+
+ // Ensure all relays are added and connected
+ relayList.forEach { (relay, metadata) ->
+ client?.addRelay(
+ url = relay,
+ capabilities =
+ if (metadata == RelayMetadata.READ) RelayCapabilities.read()
+ else if (metadata == RelayMetadata.WRITE) RelayCapabilities.write()
+ else RelayCapabilities.none()
+ )
+ client?.connectRelay(relay)
+ }
+
+ return relayList
+ }
+
+ suspend fun getDefaultMsgRelayList(): List {
+ // Construct a list of messaging relays
+ val msgRelayList = listOf(
+ RelayUrl.parse("wss://relay.0xchat.com"),
+ RelayUrl.parse("wss://nip17.com"),
+ )
+
+ // Ensure all relays are added and connected
+ msgRelayList.forEach { relay ->
+ client?.addRelay(relay, RelayCapabilities.none())
+ client?.connectRelay(relay)
+ }
+
+ return msgRelayList
+ }
+
+ suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
+ // Send relay list event
+ val relayList = getDefaultRelayList()
+ val relayListEvent = EventBuilder.relayList(relayList).signWithKeys(keys);
+
+ client?.sendEvent(
+ event = relayListEvent,
+ target = SendEventTarget.broadcast(),
+ ackPolicy = AckPolicy.all(),
+ okTimeout = Duration.parse("3s")
+ )
+
+ // Send messaging relay list event
+ val msgRelayList = getDefaultMsgRelayList()
+ val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).signWithKeys(keys)
+
+ client?.sendEvent(
+ event = msgRelayListEvent,
+ target = SendEventTarget.toNip65(),
+ ackPolicy = AckPolicy.none()
+ )
+
+ // Send metadata event
+ val metadata =
+ Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture))
+ val metadataEvent = EventBuilder.metadata(metadata).signWithKeys(keys)
+
+ client?.sendEvent(
+ event = metadataEvent,
+ target = SendEventTarget.broadcast(),
+ ackPolicy = AckPolicy.none()
+ )
+
+ // Send contact list event
+ val defaultContact =
+ listOf(Contact(publicKey = PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x")))
+ val contactListEvent = EventBuilder.contactList(defaultContact).signWithKeys(keys)
+
+ client?.sendEvent(
+ event = contactListEvent,
+ target = SendEventTarget.toNip65(),
+ ackPolicy = AckPolicy.none()
+ )
+
+ setSigner(keys)
+ }
+
+ suspend fun getAllCacheMetadata(): Map {
+ try {
+ val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(200u)
+ val events = client?.database()?.query(filter)
+ val results = mutableMapOf()
+
+ events?.toVec()?.forEach { event ->
+ val metadata = Metadata.fromJson(event.content())
+ results[event.author()] = metadata
+ }
+
+ return results
+ } catch (e: Exception) {
+ throw IllegalStateException("Failed to get cache metadata: ${e.message}", e)
+ }
+ }
+
+ suspend fun fetchMetadataBatch(keys: List) {
+ try {
+ val limit = keys.size.toULong() * 4u;
+ val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
+
+ // Construct a filter for metadata events
+ val filter = Filter()
+ .kind(Kind.fromStd(KindStandard.METADATA))
+ .authors(keys)
+ .limit(limit)
+
+ // Construct a target that includes all filters
+ val target =
+ ReqTarget.manual(
+ mapOf(
+ RelayUrl.parse("wss://purplepag.es") to listOf(filter),
+ RelayUrl.parse("wss://user.kindpag.es") to listOf(filter),
+ RelayUrl.parse("wss://relay.primal.net") to listOf(filter),
+ )
+ )
+
+ client?.subscribe(target = target, closeOn = opts)
+ } catch (e: Exception) {
+ throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e)
+ }
+ }
+
+ suspend fun setMsgRelays(urls: List) {
+ try {
+ val event = EventBuilder.nip17RelayList(urls).signAsync(signer)
+
+ client?.sendEvent(
+ event = event,
+ target = SendEventTarget.toNip65(),
+ ackPolicy = AckPolicy.none(),
+ )
+
+ val kind = Kind.fromStd(KindStandard.INBOX_RELAYS);
+ val filter = Filter().kind(kind).author(signer.currentUser!!).limit(1u)
+ val target = ReqTarget.auto(listOf(filter))
+ val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
+
+ client?.subscribe(target = target, closeOn = opts)
+ } catch (e: Exception) {
+ throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
+ }
+ }
+
+ suspend fun getMsgRelays(publicKey: PublicKey): List {
+ try {
+ val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
+ val filter = Filter().kind(kind).author(publicKey).limit(1u)
+ val events = client?.database()?.query(filter)
+
+ return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList())
+ } catch (e: Exception) {
+ throw IllegalStateException("Failed to get msg relays: ${e.message}", e)
+ }
+ }
+
+ suspend fun getRelayList(publicKey: PublicKey): Map {
+ try {
+ val kind = Kind.fromStd(KindStandard.RELAY_LIST)
+ val filter = Filter().kind(kind).author(publicKey).limit(1u)
+ val events = client?.database()?.query(filter)
+
+ return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap())
+ } catch (e: Exception) {
+ throw IllegalStateException("Failed to get relay list: ${e.message}", e)
+ }
+ }
+
+ suspend fun getChatRooms(): Set? {
+ try {
+ val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in")
+ val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
+ val kTag = SingleLetterTag.lowercase(Alphabet.K)
+
+ // Get all events sent by the user
+ val filter = Filter().kind(kind).author(userPubkey).customTag(kTag, "dm")
+ val events = client?.database()?.query(filter)
+
+ // Collect rooms
+ val roomsMap: MutableMap = mutableMapOf()
+
+ events
+ ?.toVec()
+ ?.map { UnsignedEvent.fromJson(it.content()) }
+ ?.filter { it.tags().publicKeys().isNotEmpty() }
+ ?.forEach { event ->
+ val newRoom = Room.new(rumor = event, userPubkey = userPubkey)
+ val existingRoom = roomsMap[newRoom.id]
+
+ // Check if the room already exists
+ if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
+ val filter =
+ Filter().kind(kind).author(userPubkey).pubkeys(newRoom.members.toList())
+
+ // Determine if it's an ongoing room
+ val isOngoing = client?.database()?.query(filter)?.isEmpty() == false
+
+ // Append room to map
+ roomsMap[newRoom.id] =
+ if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom
+ }
+ }
+
+ return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet()
+ } catch (e: Exception) {
+ println("Failed to get chat rooms: ${e.message}")
+ return null
+ }
+ }
+
+ suspend fun getChatRoomMessages(roomId: Long): List {
+ try {
+ val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
+ val filter = Filter().kind(kind).reference(roomId.toString())
+ val events = client?.database()?.query(filter)
+
+ // Merge the events
+ return events
+ ?.toVec()
+ ?.map { UnsignedEvent.fromJson(it.content()) }
+ ?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
+ } catch (e: Exception) {
+ throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
+ }
+ }
+
+ suspend fun chatRoomConnect(members: List): Map> {
+ try {
+ val results = mutableMapOf>()
+
+ members.forEach { member ->
+ results[member] = mutableListOf()
+ val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
+ val filter = Filter().kind(kind).author(member).limit(1u)
+
+ val stream = client?.streamEvents(
+ target = ReqTarget.auto(listOf(filter)),
+ id = "room-${member.toBech32().substring(0, 10)}",
+ timeout = Duration.parse("3s"),
+ policy = ReqExitPolicy.ExitOnEose
+ )
+
+ stream?.next()?.let { res ->
+ if (res.event != null) {
+ // Connect to the msg relays
+ connectMsgRelays(res.event!!)
+ // Mark the member as connected
+ results[member]?.add(res.relayUrl)
+ }
+ }
+ }
+
+ return results
+ } catch (e: Exception) {
+ throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
+ }
+ }
+
+ suspend fun connectMsgRelays(event: Event) {
+ try {
+ val urls = nip17ExtractRelayList(event);
+ for (url in urls) {
+ if (client?.relay(url) == null) {
+ client?.addRelay(url)
+ client?.connectRelay(url)
+ }
+ }
+ } catch (e: Exception) {
+ throw IllegalStateException("Failed to connect to relays: ${e.message}", e)
+ }
+ }
+
+ suspend fun sendMessage(
+ to: List,
+ content: String,
+ subject: String? = null,
+ replies: List = emptyList(),
+ onRumorCreated: ((UnsignedEvent) -> Unit)? = null,
+ ) {
+ try {
+ val currentUser =
+ signer.currentUser ?: throw IllegalStateException("User not signed in")
+
+ val tags = mutableListOf()
+
+ // Add a subject tag if provided
+ if (subject != null) {
+ tags.add(Tag.custom(TagKind.Subject, listOf(subject)))
+ }
+
+ // Add event tags for replies
+ if (replies.isNotEmpty()) {
+ replies.forEach { replyId ->
+ tags.add(Tag.event(replyId))
+ }
+ }
+
+ // Add public key tags for each recipient
+ to.forEach { pubkey ->
+ if (pubkey != currentUser) {
+ tags.add(Tag.publicKey(pubkey))
+ }
+ }
+
+ for (receiver in listOf(currentUser) + to) {
+ // Construct the rumor event
+ // NEVER SIGN this event with the current user signer
+ val rumor = EventBuilder
+ .privateMsgRumor(receiver = receiver, message = content)
+ .tags(tags)
+ .build(currentUser)
+ // Ensure the event ID is set
+ .ensureId()
+
+ // Emit the rumor to the chat screen
+ if (receiver == currentUser) {
+ onRumorCreated?.invoke(rumor)
+ }
+
+ // Construct the gift wrap event
+ val gift = giftWrapAsync(
+ signer = signer,
+ receiverPubkey = receiver,
+ rumor = rumor,
+ extraTags = listOf(
+ Tag.custom(TagKind.Unknown("k"), listOf("14"))
+ )
+ )
+
+ // Send the event to receiver's NIP-17 relays
+ val output = client?.sendEvent(
+ event = gift,
+ target = SendEventTarget.toNip17(),
+ ackPolicy = AckPolicy.none(),
+ authenticationTimeout = Duration.parse("2s")
+ )
+
+ if (output != null) {
+ // Keep track of sent events
+ sentEvents[output.id] = emptyList()
+ if (rumor.id() != null) rumorMap[rumor.id()!!] = output.id
+
+ // Collect failed outputs
+ output.failed.forEach { (relayUrl, reason) ->
+ println("Failed to send event to relay $relayUrl: $reason")
+ }
+ }
+ }
+ } catch (e: Exception) {
+ 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): PublicKey {
+ try {
+ val address = Nip05Address.parse(query)
+ val profile = profileFromAddress(HttpClient(), address)
+
+ return profile.publicKey()
+ } catch (e: Exception) {
+ throw IllegalStateException("Failed to search address: ${e.message}", e)
+ }
+ }
+
+ suspend fun searchByNostr(query: String): List {
+ try {
+ // Add search relay
+ val searchRelay = RelayUrl.parse("wss://antiprimal.net")
+ if (client?.relay(searchRelay) == null) {
+ client?.addRelay(url = searchRelay, capabilities = RelayCapabilities.read())
+ client?.connectRelay(searchRelay)
+ }
+
+ 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())
+ }
+ }
+
+ 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
new file mode 100644
index 0000000..3699031
--- /dev/null
+++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
@@ -0,0 +1,533 @@
+package su.reya.coop
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import io.ktor.client.HttpClient
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.serialization.kotlinx.json.json
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
+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.RelayMetadata
+import rust.nostr.sdk.RelayUrl
+import rust.nostr.sdk.Tag
+import rust.nostr.sdk.UnsignedEvent
+import su.reya.coop.blossom.BlossomClient
+import su.reya.coop.storage.SecretStorage
+import kotlin.time.Clock
+import kotlin.time.Duration
+
+class NostrViewModel(
+ private val nostr: Nostr,
+ private val secretStore: SecretStorage
+) : ViewModel() {
+ private val _emptySecret = MutableStateFlow(null)
+ val emptySecret = _emptySecret.asStateFlow()
+
+ private val _isCreating = MutableStateFlow(false)
+ val isCreating = _isCreating.asStateFlow()
+
+ private val _chatRooms = MutableStateFlow>(emptySet())
+ val chatRooms = _chatRooms.asStateFlow()
+
+ private val _contactList = MutableStateFlow>(emptySet())
+ val contactList = _contactList.asStateFlow()
+
+ private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
+ val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
+
+ private val _isRelayListEmpty = MutableStateFlow(false)
+ val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
+
+ private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100)
+ val newEvents = _newEvents.asSharedFlow()
+
+ private val _sentReports = MutableStateFlow