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>>(emptyMap()) + val sentReport = _sentReports.asSharedFlow() + + private val _errorEvents = Channel(Channel.BUFFERED) + val errorEvents = _errorEvents.receiveAsFlow() + + private val _metadataStore = mutableMapOf>() + private val metadataRequestChannel = Channel(Channel.UNLIMITED) + private val seenPublicKeys = mutableSetOf() + + init { + startNotificationHandler() + startMetadataBatchHandler() + getCacheMetadata() + login() + observeSignerAndCheckRelays() + } + + override fun onCleared() { + super.onCleared() + // Ensure all relays are disconnect + viewModelScope.launch { + withContext(NonCancellable) { + nostr.disconnect() + } + } + } + + private fun showError(message: String) { + viewModelScope.launch { + _errorEvents.send(message) + if (isCreating.value) _isCreating.value = false + } + } + + private fun startNotificationHandler() { + viewModelScope.launch { + // Wait until the client is ready + nostr.waitUntilInitialized() + + nostr.handleNotifications( + onMetadataUpdate = { pubkey, metadata -> + updateMetadata(pubkey, metadata) + }, + onContactListUpdate = { contactList -> + _contactList.value = contactList.toSet() + }, + onSubscriptionClose = { + getChatRooms() + + if (!_isPartialProcessedGiftWrap.value) { + _isPartialProcessedGiftWrap.value = true + } + }, + onNewMessage = { event -> + viewModelScope.launch { + _newEvents.emit(event) + } + }, + ) + } + } + + private fun startMetadataBatchHandler() { + viewModelScope.launch { + // Wait until the client is ready + nostr.waitUntilInitialized() + + val batch = mutableSetOf() + val timeout = 500L // 500ms timeout for batching + + while (true) { + val firstKey = metadataRequestChannel.receive() + batch.add(firstKey) + val lastFlushTime = Clock.System.now().toEpochMilliseconds() + + while (batch.isNotEmpty()) { + val nextKey = withTimeoutOrNull(timeout) { + metadataRequestChannel.receive() + } + + if (nextKey != null) { + batch.add(nextKey) + } + + val now = Clock.System.now().toEpochMilliseconds() + if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) { + val keysToRequest = batch.toList() + batch.clear() + + nostr.fetchMetadataBatch(keysToRequest) + } + } + } + } + } + + private fun getCacheMetadata() { + viewModelScope.launch { + // Wait until the client is ready + nostr.waitUntilInitialized() + + val results = nostr.getAllCacheMetadata() + results.forEach { (pubkey, metadata) -> + updateMetadata(pubkey, metadata) + seenPublicKeys.add(pubkey) + } + } + } + + private fun login() { + viewModelScope.launch { + // Wait until the client is ready + nostr.waitUntilInitialized() + + // Get user's signer secret + val secret = secretStore.get("user_signer") + + // If no secret is found, show onboarding screen + when (secret) { + null -> { + _emptySecret.value = true + return@launch + } + + else -> _emptySecret.value = false + } + + // Handle different signer types + if (secret.startsWith("nsec1")) { + val keys = Keys.parse(secret) + nostr.setSigner(keys) + } else if (secret.startsWith("bunker://")) { + try { + val appKeys = getOrInitAppKeys() + val bunker = NostrConnectUri.parse(secret) + val timeout = Duration.parse("50s") // 50 seconds timeout + val remote = + NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null) + nostr.setSigner(remote) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } else { + throw IllegalArgumentException("Invalid secret format: $secret") + } + } + } + + private fun observeSignerAndCheckRelays() { + viewModelScope.launch { + while (true) { + val pubkey = nostr.signer.currentUser + + if (pubkey != null) { + delay(3000) + val relays = nostr.getMsgRelays(pubkey) + if (relays.isEmpty()) { + _isRelayListEmpty.value = true + } + break + } + + delay(1000) + } + } + } + + private fun requestMetadata(pubkey: PublicKey) { + if (seenPublicKeys.add(pubkey)) { + viewModelScope.launch { + metadataRequestChannel.send(pubkey) + } + } + } + + private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) { + _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata + } + + fun getMetadata(pubkey: PublicKey): StateFlow { + val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) } + if (flow.value == null) { + requestMetadata(pubkey) + } + return flow.asStateFlow() + } + + fun currentUser(): PublicKey? { + return nostr.signer.currentUser + } + + fun logout() { + viewModelScope.launch { + secretStore.clear("user_signer") + nostr.signer.switch(Keys.generate()) + _emptySecret.value = true + } + } + + fun dismissRelayWarning() { + _isRelayListEmpty.value = false + } + + private suspend fun getOrInitAppKeys(): Keys { + val secret = secretStore.get("app_keys") + + // If app keys are already stored, use them + if (secret != null) { + return Keys.parse(secret) + } + + // Generate new app keys and save to the secret storage + val keys = Keys.generate() + secretStore.set("app_keys", keys.secretKey().toBech32()) + + return keys + } + + fun createIdentity( + name: String, + bio: String?, + picture: ByteArray?, + contentType: String? = null + ) { + viewModelScope.launch { + try { + val keys = Keys.generate() + val secret = keys.secretKey().toBech32() + var avatarUrl = "" + + // Set loading state + _isCreating.value = true + + // Upload picture to Blossom + if (picture != null) { + val blossom = BlossomClient( + url = "https://blossom.band", + client = HttpClient { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + prettyPrint = true + isLenient = true + }) + } + } + ) + + val descriptor = blossom.upload( + file = picture, + contentType = contentType, + signer = keys + ) + + avatarUrl = descriptor?.url ?: "" + } + + // Create identity + nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl) + + // Save secret to the secret storage + secretStore.set("user_signer", secret) + + // Set an empty secret state + _emptySecret.value = false + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + suspend fun verifyIdentity(secret: String): PublicKey? { + if (secret.startsWith("nsec1")) { + val keys = Keys.parse(secret) + return keys.publicKey() + } else if (secret.startsWith("bunker://")) { + val appKeys = getOrInitAppKeys() + val bunker = NostrConnectUri.parse(secret) + val timeout = Duration.parse("50s") // 50 seconds timeout + val remote = NostrConnect(uri = bunker, appKeys, timeout, null) + + // Show toast to ask user to approve the connection + showError("Please approve the connection.") + + return remote.getPublicKeyAsync() + } else { + throw IllegalArgumentException("Invalid secret: $secret") + } + } + + fun importIdentity(secret: String) { + viewModelScope.launch { + if (secret.startsWith("nsec1")) { + val keys = Keys.parse(secret) + nostr.setSigner(keys) + secretStore.set("user_signer", secret) + // Set an empty secret state + _emptySecret.value = false + } else if (secret.startsWith("bunker://")) { + try { + val appKeys = getOrInitAppKeys() + val bunker = NostrConnectUri.parse(secret) + val timeout = Duration.parse("50s") // 50 seconds timeout + val remote = + NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null) + nostr.setSigner(remote) + secretStore.set("user_signer", secret) + // Set an empty secret state + _emptySecret.value = false + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } else { + showError("Please enter a valid Secret or Bunker URI.") + } + } + } + + suspend fun useDefaultMsgRelayList() { + try { + val defaultRelays = nostr.getDefaultMsgRelayList() + nostr.setMsgRelays(defaultRelays) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + + suspend fun currentUserRelayList(): Map { + try { + return nostr.getRelayList(nostr.signer.currentUser!!) + } catch (e: Exception) { + showError("Error: ${e.message}") + return emptyMap() + } + } + + suspend fun currentUserMsgRelayList(): List { + try { + return nostr.getMsgRelays(nostr.signer.currentUser!!) + } catch (e: Exception) { + showError("Error: ${e.message}") + return emptyList() + } + } + + fun createChatRoom(to: List): Long { + if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in") + if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required") + + // Construct the rumor event + val rumor = EventBuilder + .privateMsgRumor(to.first(), "") + .tags(to.map { Tag.publicKey(it) }) + .build(nostr.signer.currentUser!!) + + // Create a room from the rumor event + val room = Room.new(rumor, nostr.signer.currentUser!!) + _chatRooms.value += room + + return room.id + } + + fun getChatRoom(id: Long): Room { + return chatRooms.value.firstOrNull { it.id == id } + ?: throw IllegalArgumentException("Room not found") + } + + fun getChatRooms() { + viewModelScope.launch { + try { + _chatRooms.value = nostr.getChatRooms() ?: emptySet() + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + suspend fun refreshChatRooms() { + try { + _chatRooms.value = nostr.getChatRooms() ?: emptySet() + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + + suspend fun getChatRoomMessages(roomId: Long): List { + try { + return nostr.getChatRoomMessages(roomId) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + + return emptyList() + } + + suspend fun chatRoomConnect(roomId: Long): Map> { + val room = getChatRoom(roomId) + val members = room.members + + return runCatching { + nostr.chatRoomConnect(members.toList()) + }.getOrElse { e -> + showError("Error: ${e.message}") + members.associateWith { emptyList() } + } + } + + fun sendMessage(roomId: Long, message: String, replies: List = emptyList()) { + viewModelScope.launch { + try { + val room = getChatRoom(roomId) + nostr.sendMessage( + to = room.members.toList(), + content = message, + subject = room.subject, + replies = replies, + onRumorCreated = { event -> + updateRoomList(roomId, event) + viewModelScope.launch { _newEvents.emit(event) } + }, + ) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun isMessageSent(id: EventId): Boolean { + val giftWrapId = nostr.rumorMap[id] + + if (giftWrapId != null) { + val isSent = nostr.sentEvents[giftWrapId]?.isNotEmpty() ?: false + return isSent + } else { + return false + } + } + + private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) { + _chatRooms.value = _chatRooms.value.map { room -> + if (room.id == roomId) { + room.copy(lastMessage = newMessage.content(), createdAt = newMessage.createdAt()) + } else { + room + } + }.toSet() + } + + suspend fun searchByAddress(query: String): PublicKey? { + try { + return nostr.searchByAddress(query) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + return null + } + + suspend fun searchByNostr(query: String): List { + try { + return nostr.searchByNostr(query) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + return emptyList() + } +} + +fun PublicKey.short(): String { + val bech32 = toBech32() + return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4) +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Platform.kt b/shared/src/commonMain/kotlin/su/reya/coop/Platform.kt deleted file mode 100644 index 55add4c..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/Platform.kt +++ /dev/null @@ -1,7 +0,0 @@ -package su.reya.coop - -interface Platform { - val name: String -} - -expect fun getPlatform(): Platform \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt new file mode 100644 index 0000000..3e2ff19 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -0,0 +1,171 @@ +package su.reya.coop + +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.TimeZone +import kotlinx.datetime.minus +import kotlinx.datetime.number +import kotlinx.datetime.toLocalDateTime +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.TagKind +import rust.nostr.sdk.Timestamp +import rust.nostr.sdk.UnsignedEvent +import kotlin.time.Clock +import kotlin.time.Instant + +enum class RoomKind { + Ongoing, + Request; + + companion object { + fun default(): RoomKind = Request + } +} + +data class Room( + val id: Long, + val createdAt: Timestamp, + val subject: String?, + val members: Set, + val kind: RoomKind = RoomKind.default(), + val lastMessage: String? = null +) : Comparable { + override fun compareTo(other: Room): Int { + return this.createdAt.asSecs().compareTo(other.createdAt.asSecs()) + } + + companion object { + fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room { + val id = rumor.roomId() + val createdAt = rumor.createdAt() + val subject = rumor.tags().find(TagKind.Subject)?.content() + + // Collect the author's public key and all public keys from tags + // Also remove the user's public key from the list, current user is always a member + val pubkeys: MutableSet = mutableSetOf() + pubkeys.add(rumor.author()) + pubkeys.addAll(rumor.tags().publicKeys()) + pubkeys.remove(userPubkey) + + // Create a new Room instance + return Room( + id = id, + createdAt = createdAt, + subject = subject, + members = pubkeys as Set, + lastMessage = rumor.content() + ) + } + } + + fun setKind(kind: RoomKind): Room { + return this.copy(kind = kind) + } + + fun setCreatedAt(createdAt: Timestamp): Room { + return this.copy(createdAt = createdAt) + } + + fun setSubject(subject: String?): Room { + return this.copy(subject = subject) + } + + fun setLastMessage(message: String?): Room { + return this.copy(lastMessage = message) + } + + fun isGroup(): Boolean { + return members.size > 1 + } +} + +fun UnsignedEvent.roomId(): Long { + // Collect the author's public key and all public keys from tags + val pubkeys: MutableList = mutableListOf() + pubkeys.add(this.author()) + pubkeys.addAll(this.tags().publicKeys()) + + // Sort and hash the list of public keys + val sortedUniqueKeys = pubkeys + .distinctBy { it.toBech32() } + .sortedBy { it.toBech32() } + + return sortedUniqueKeys.hashCode().toLong() +} + +fun Timestamp.ago(): String { + val SECONDS_IN_MINUTE = 60L + val MINUTES_IN_HOUR = 60L + val HOURS_IN_DAY = 24L + val DAYS_IN_MONTH = 30L + + val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong()) + val now = Clock.System.now() + val duration = now - inputInstant + + return when { + duration.inWholeSeconds < SECONDS_IN_MINUTE -> "Now" + duration.inWholeMinutes < MINUTES_IN_HOUR -> "${duration.inWholeMinutes}m" + duration.inWholeHours < HOURS_IN_DAY -> "${duration.inWholeHours}h" + duration.inWholeDays < DAYS_IN_MONTH -> "${duration.inWholeDays}d" + else -> { + val localDateTime = inputInstant.toLocalDateTime(TimeZone.currentSystemDefault()) + val month = + localDateTime.month.name.take(3).lowercase().replaceFirstChar { it.uppercase() } + val day = localDateTime.dayOfMonth.toString().padStart(2, '0') + "$month $day" + } + } +} + +fun Timestamp.formatAsGroupHeader(): String { + val timeZone = TimeZone.currentSystemDefault() + val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong()) + val inputDate = inputInstant.toLocalDateTime(timeZone).date + + val now = Clock.System.now() + val today = now.toLocalDateTime(timeZone).date + val yesterday = today.minus(1, DateTimeUnit.DAY) + + return when (inputDate) { + today -> "Today" + yesterday -> "Yesterday" + else -> { + val day = inputDate.day.toString().padStart(2, '0') + val month = inputDate.month.number.toString().padStart(2, '0') + val year = inputDate.year.toString().takeLast(2) + "$day/$month/$year" + } + } +} + +fun Timestamp.humanReadable(): String { + val timeZone = TimeZone.currentSystemDefault() + val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong()) + val inputDateTime = inputInstant.toLocalDateTime(timeZone) + val inputDate = inputDateTime.date + + val now = Clock.System.now() + val today = now.toLocalDateTime(timeZone).date + val yesterday = today.minus(1, DateTimeUnit.DAY) + + val hour = inputDateTime.hour + val minute = inputDateTime.minute.toString().padStart(2, '0') + val amPm = if (hour < 12) "AM" else "PM" + val hour12 = when { + hour == 0 -> 12 + hour > 12 -> hour - 12 + else -> hour + } + val timeFormat = "$hour12:$minute $amPm" + + return when (inputDate) { + today -> "Today at $timeFormat" + yesterday -> "Yesterday at $timeFormat" + else -> { + val day = inputDateTime.day.toString().padStart(2, '0') + val month = inputDateTime.month.number.toString().padStart(2, '0') + val year = inputDateTime.year.toString().takeLast(2) + "$day/$month/$year, $timeFormat" + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Signer.kt b/shared/src/commonMain/kotlin/su/reya/coop/Signer.kt new file mode 100644 index 0000000..5e87674 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/Signer.kt @@ -0,0 +1,55 @@ +package su.reya.coop + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import rust.nostr.sdk.AsyncNostrSigner +import rust.nostr.sdk.Event +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.UnsignedEvent + +class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner { + private val mutex = Mutex() + private var signer: AsyncNostrSigner = initialSigner + + var currentUser: PublicKey? = null + private set + + /** + * Get the current signer. + */ + suspend fun get(): AsyncNostrSigner = mutex.withLock { + signer + } + + /** + * Switch to a new signer. + */ + suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock { + signer = newSigner + currentUser = newSigner.getPublicKeyAsync() + } + + override suspend fun getPublicKeyAsync(): PublicKey? { + return get().getPublicKeyAsync() + } + + override suspend fun signEventAsync(unsignedEvent: UnsignedEvent): Event? { + return get().signEventAsync(unsignedEvent) + } + + override suspend fun nip04EncryptAsync(publicKey: PublicKey, content: String): String { + return get().nip04EncryptAsync(publicKey, content) + } + + override suspend fun nip04DecryptAsync(publicKey: PublicKey, encryptedContent: String): String { + return get().nip04DecryptAsync(publicKey, encryptedContent) + } + + override suspend fun nip44EncryptAsync(publicKey: PublicKey, content: String): String { + return get().nip44EncryptAsync(publicKey, content) + } + + override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String { + return get().nip44DecryptAsync(publicKey, payload) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/blossom/BlossomClient.kt b/shared/src/commonMain/kotlin/su/reya/coop/blossom/BlossomClient.kt new file mode 100644 index 0000000..6ba145b --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/blossom/BlossomClient.kt @@ -0,0 +1,83 @@ +package su.reya.coop.blossom + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.header +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.http.HeaderValue +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.utils.io.core.toByteArray +import okio.ByteString.Companion.toByteString +import rust.nostr.sdk.AsyncNostrSigner +import rust.nostr.sdk.EventBuilder +import rust.nostr.sdk.Timestamp +import kotlin.io.encoding.Base64 +import kotlin.time.Duration + +class BlossomClient( + val url: String, + val client: HttpClient, +) { + suspend fun upload( + file: ByteArray, + contentType: String? = null, + signer: AsyncNostrSigner? = null + ): BlobDescriptor? { + val url = "$url/upload" + val hash = file.toByteString().sha256().hex() + val fileHashes = listOf(hash) + + val res = client.put(url) { + // Set body + setBody(file) + + // Set the content type if provided + contentType?.let { + header(HttpHeaders.ContentType, it) + } + + signer?.let { + val defaultAuth = defaultAuth( + action = BlossomAuthorizationVerb.Upload, + defaultContent = "Blossom upload authorization", + defaultScope = BlossomAuthorizationScope.BlobSha256Hashes(fileHashes) + ) + val authHeader = buildAuthHeader(it, defaultAuth) + header(HttpHeaders.Authorization, authHeader.value) + } + } + + return when (res.status) { + HttpStatusCode.OK -> res.body() + else -> { + throw Exception("Failed to upload file: ${res.status}") + } + } + } + + fun defaultAuth( + action: BlossomAuthorizationVerb, + defaultContent: String, + defaultScope: BlossomAuthorizationScope + ): BlossomAuthorization { + val expiration = Timestamp.now().addDuration(Duration.parse("300s")) + return BlossomAuthorization( + content = defaultContent, + expiration = expiration, + action = action, + scope = defaultScope + ) + } + + suspend fun buildAuthHeader( + signer: AsyncNostrSigner, + authz: BlossomAuthorization + ): HeaderValue { + val authEvent = EventBuilder.blossomAuth(authz).signAsync(signer) + val encodedAuth = Base64.encode(authEvent.asJson().toByteArray()) + val value = "Nostr $encodedAuth" + return HeaderValue(value) + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud01.kt b/shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud01.kt new file mode 100644 index 0000000..073d629 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud01.kt @@ -0,0 +1,92 @@ +package su.reya.coop.blossom + +import rust.nostr.sdk.EventBuilder +import rust.nostr.sdk.Kind +import rust.nostr.sdk.Tag +import rust.nostr.sdk.Timestamp + +/** + * Represents the authorization data for accessing a Blossom server. + */ +data class BlossomAuthorization( + /** + * A human readable string explaining to the user what the events intended use is + */ + val content: String, + /** + * A UNIX timestamp (in seconds) indicating when the authorization should be expired + */ + val expiration: Timestamp, + /** + * The type of action authorized by the user + */ + val action: BlossomAuthorizationVerb, + /** + * The scope of the authorization + */ + val scope: BlossomAuthorizationScope, +) + +/** + * The scope of a Blossom authorization event + */ +sealed class BlossomAuthorizationScope { + /** + * Authorizes access to blobs with the given SHA256 hashes. + */ + data class BlobSha256Hashes(val hashes: List) : BlossomAuthorizationScope() + + /** + * Authorizes access to the given server URL. + */ + data class ServerUrl(val url: String) : BlossomAuthorizationScope() + + fun toTags(): List { + return when (this) { + is BlobSha256Hashes -> hashes.map { hash -> + // "x" tag for blob hash + Tag.parse(listOf("x", hash)) + } + + is ServerUrl -> listOf( + // "server" tag for server URL + Tag.parse(listOf("server", url)) + ) + } + } +} + +/** + * Represents the possible actions that can be authorized by a Blossom authorization event. + */ +enum class BlossomAuthorizationVerb(val value: String) { + Get("get"), + Upload("upload"), + List("list"), + Delete("delete"); + + override fun toString(): String = value +} + +/** + * Extension functions for [BlossomAuthorization] and [EventBuilder]. + */ +fun BlossomAuthorization.toTags(): List { + val tags = mutableListOf() + tags.addAll(scope.toTags()) + tags.add(Tag.expiration(expiration)) + // Add the 't' tag to say what this auth is for + tags.add(Tag.hashtag(action.toString())) + return tags +} + +/** + * Blossom authorization event (Kind 24242) + * + * https://github.com/hzrd149/blossom/blob/master/buds/01.md + */ +fun EventBuilder.Companion.blossomAuth(authorization: BlossomAuthorization): EventBuilder { + // Kind 24242 is used for Blossom Auth + val kind = Kind(24242u) + return EventBuilder(kind, authorization.content).tags(authorization.toTags()) +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud02.kt b/shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud02.kt new file mode 100644 index 0000000..b4eab0d --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud02.kt @@ -0,0 +1,27 @@ +package su.reya.coop.blossom + +import kotlinx.serialization.Serializable + +@Serializable +data class BlobDescriptor( + /** + * The URL at which the blob/file can be accessed + */ + val url: String, + /** + * The SHA256 hash of the contents in the blob + */ + val sha256: String, + /** + * The size of the blob/file, in bytes + */ + val size: Long, + /** + * Mime type of the blob/file + */ + val mimeType: String? = null, + /** + * The date at which the blob was uploaded, as a UNIX timestamp (in seconds) + */ + val uploaded: ULong +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/storage/SecretStorage.kt b/shared/src/commonMain/kotlin/su/reya/coop/storage/SecretStorage.kt new file mode 100644 index 0000000..177dc00 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/storage/SecretStorage.kt @@ -0,0 +1,8 @@ +package su.reya.coop.storage + +interface SecretStorage { + suspend fun get(key: String): String? + suspend fun set(key: String, value: String) + suspend fun clear(key: String) + suspend fun has(key: String): Boolean +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/su/reya/coop/Platform.ios.kt b/shared/src/iosMain/kotlin/su/reya/coop/Platform.ios.kt deleted file mode 100644 index aaf7b28..0000000 --- a/shared/src/iosMain/kotlin/su/reya/coop/Platform.ios.kt +++ /dev/null @@ -1,9 +0,0 @@ -package su.reya.coop - -import platform.UIKit.UIDevice - -class IOSPlatform: Platform { - override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion -} - -actual fun getPlatform(): Platform = IOSPlatform() \ No newline at end of file