From 6295378b787734635a012530ec6c0f54e43e9853 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 22 Apr 2026 20:03:33 +0700 Subject: [PATCH 01/43] chore: add nostr service --- .../kotlin/su/reya/coop/MainActivity.kt | 18 ++++++++++++ gradle.properties | 12 +++++++- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- shared/build.gradle.kts | 2 +- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 28 +++++++++++++++++++ 6 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index 9b64f44..c7a3d8f 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -6,15 +6,33 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import java.io.File class MainActivity : ComponentActivity() { + private val nostr = Nostr() + override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) + // Get database directory + val dbDir = File(filesDir, "nostr") + dbDir.mkdirs() + + // Initialize nostr client + nostr.init(dbDir.absolutePath) + + // Connect to bootstrap relays + lifecycleScope.launch { + nostr.connect() + } + setContent { App() } + } } 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..76c4c43 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.13.2" +agp = "9.2.0" android-compileSdk = "36" android-minSdk = "24" android-targetSdk = "36" 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..eb41203 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -24,7 +24,7 @@ kotlin { sourceSets { commonMain.dependencies { - // put your Multiplatform dependencies here + implementation("org.rust-nostr:nostr-sdk-kmp:0.44.3") } commonTest.dependencies { implementation(libs.kotlin.test) 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..360aa97 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -0,0 +1,28 @@ +package su.reya.coop + +import rust.nostr.sdk.Client +import rust.nostr.sdk.ClientBuilder +import rust.nostr.sdk.ClientOptions +import rust.nostr.sdk.NostrDatabase +import rust.nostr.sdk.NostrGossip +import rust.nostr.sdk.RelayUrl + +class Nostr { + var client: Client? = null + private set + + fun init(dbPath: String) { + val lmdb = NostrDatabase.lmdb(dbPath) + val gossip = NostrGossip.inMemory() + val opts = ClientOptions().automaticAuthentication(false) + + client = ClientBuilder().database(lmdb).gossip(gossip).opts(opts).build() + } + + suspend fun connect() { + this.client?.addRelay(RelayUrl.parse("wss://relay.damus.io")) + this.client?.addRelay(RelayUrl.parse("wss://relay.primal.net")) + this.client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) + this.client?.connect() + } +} -- 2.49.1 From fc0d6b60570d44740b300d4926f2546f681e9122 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 23 Apr 2026 13:50:51 +0700 Subject: [PATCH 02/43] android: add secret storage --- composeApp/build.gradle.kts | 5 +- .../su/reya/coop/storage/SecretCrypto.kt | 78 +++++++++++++++++++ .../su/reya/coop/storage/SecretStore.kt | 42 ++++++++++ shared/build.gradle.kts | 4 +- 4 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretCrypto.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretStore.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b1ac2b7..a6af89a 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 { @@ -14,11 +13,13 @@ kotlin { jvmTarget.set(JvmTarget.JVM_11) } } - + sourceSets { androidMain.dependencies { implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.activity.compose) + implementation("androidx.datastore:datastore-preferences:1.2.1") + implementation("androidx.datastore:datastore-preferences-core:1.2.1") } commonMain.dependencies { implementation(libs.compose.runtime) 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..e376167 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretStore.kt @@ -0,0 +1,42 @@ +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 + +private val Context.dataStore by preferencesDataStore("secret_store") + +class SecretStore(private val context: Context) { + private val crypto = SecretCrypto() + + 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 + } + } + + 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)) + } + + suspend fun clear(name: String) { + context.dataStore.edit { prefs -> + prefs.remove(stringPreferencesKey("${name}_encrypted")) + prefs.remove(stringPreferencesKey("${name}_iv")) + } + } + + suspend fun has(name: String): Boolean { + val prefs = context.dataStore.data.first() + return prefs[stringPreferencesKey("${name}_encrypted")] != null + } +} \ No newline at end of file diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index eb41203..84dfc7b 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -11,7 +11,7 @@ kotlin { jvmTarget.set(JvmTarget.JVM_11) } } - + listOf( iosArm64(), iosSimulatorArm64() @@ -21,7 +21,7 @@ kotlin { isStatic = true } } - + sourceSets { commonMain.dependencies { implementation("org.rust-nostr:nostr-sdk-kmp:0.44.3") -- 2.49.1 From 8c6b70304d8138d55f7128f26eeaeab70cee41fb Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 23 Apr 2026 14:42:35 +0700 Subject: [PATCH 03/43] android: add basic screens --- composeApp/build.gradle.kts | 3 + .../androidMain/kotlin/su/reya/coop/App.kt | 107 ++++++++++++------ .../kotlin/su/reya/coop/MainActivity.kt | 1 - .../kotlin/su/reya/coop/Screens.kt | 98 ++++++++++++++++ gradle/libs.versions.toml | 4 + 5 files changed, 176 insertions(+), 37 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/Screens.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index a6af89a..1db42c0 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -5,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 { @@ -18,6 +19,8 @@ kotlin { androidMain.dependencies { implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.activity.compose) + implementation("androidx.navigation:navigation-compose:2.8.8") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0") implementation("androidx.datastore:datastore-preferences:1.2.1") implementation("androidx.datastore:datastore-preferences-core:1.2.1") } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 6e03338..42222e4 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -1,48 +1,83 @@ package su.reya.coop -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.safeContentPadding -import androidx.compose.material3.Button +import android.content.Context import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.* -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.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch -import coop.composeapp.generated.resources.Res -import coop.composeapp.generated.resources.compose_multiplatform +private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") +private val FIRST_TIME_KEY = booleanPreferencesKey("first_time") @Composable -@Preview fun App() { MaterialTheme { - var showContent by remember { mutableStateOf(false) } - Column( - modifier = Modifier - .background(MaterialTheme.colorScheme.primaryContainer) - .safeContentPadding() - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Button(onClick = { showContent = !showContent }) { - Text("Click me!") + val context = LocalContext.current + val scope = rememberCoroutineScope() + val navController = rememberNavController() + + val isFirstTimeFlow = remember { + context.dataStore.data.map { preferences -> + preferences[FIRST_TIME_KEY] ?: true } - AnimatedVisibility(showContent) { - val greeting = remember { Greeting().greet() } - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image(painterResource(Res.drawable.compose_multiplatform), null) - Text("Compose: $greeting") - } + } + val isFirstTime by isFirstTimeFlow.collectAsState(initial = null) + + if (isFirstTime == null) { + // Loading state + return@MaterialTheme + } + + NavHost( + navController = navController, + startDestination = if (isFirstTime == true) Screen.Welcome else Screen.Home + ) { + composable { backStackEntry -> + WelcomeScreen(onContinue = { + scope.launch { + context.dataStore.edit { settings -> + settings[FIRST_TIME_KEY] = false + } + navController.navigate(Screen.Home) { + popUpTo { inclusive = true } + } + } + }) + } + composable { backStackEntry -> + HomeScreen( + onOpenChat = { id -> navController.navigate(Screen.Chat(id)) } + ) + } + composable { backStackEntry -> + val chat: Screen.Chat = backStackEntry.toRoute() + ChatScreen(id = chat.id) + } + composable { backStackEntry -> + OnboardingScreen( + onOpenImport = { navController.navigate(Screen.Import) }, + onOpenNew = { navController.navigate(Screen.New) } + ) + } + composable { backStackEntry -> + ImportScreen() + } + composable { backStackEntry -> + NewScreen() } } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index c7a3d8f..2ba9751 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -32,7 +32,6 @@ class MainActivity : ComponentActivity() { setContent { App() } - } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Screens.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Screens.kt new file mode 100644 index 0000000..47ff239 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Screens.kt @@ -0,0 +1,98 @@ +package su.reya.coop + +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.height +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.serialization.Serializable + +sealed interface Screen { + @Serializable + data object Welcome : Screen + + @Serializable + data object Home : Screen + + @Serializable + data class Chat(val id: String) : Screen + + @Serializable + data object Onboarding : Screen + + @Serializable + data object Import : Screen + + @Serializable + data object New : Screen +} + +@Composable +fun WelcomeScreen(onContinue: () -> Unit) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Welcome Screen") + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onContinue) { + Text("Get Started") + } + } + } +} + +@Composable +fun HomeScreen(onOpenChat: (String) -> Unit) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Home Screen") + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { onOpenChat("123") }) { + Text("Open Chat 123") + } + } + } +} + +@Composable +fun ChatScreen(id: String) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Chat Screen (ID: $id)") + } +} + +@Composable +fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Onboarding Screen") + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onOpenImport) { + Text("Import") + } + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = onOpenNew) { + Text("New") + } + } + } +} + +@Composable +fun ImportScreen() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Import Screen") + } +} + +@Composable +fun NewScreen() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("New Screen") + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 76c4c43..5cbba71 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,10 +8,12 @@ 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" [libraries] @@ -23,6 +25,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" } -- 2.49.1 From 32403824986450d5f6d0761eb3558c8a0d10671c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 25 Apr 2026 08:52:42 +0700 Subject: [PATCH 04/43] add nostr view model --- composeApp/build.gradle.kts | 1 + .../androidMain/kotlin/su/reya/coop/App.kt | 72 ++++++++----------- .../kotlin/su/reya/coop/MainActivity.kt | 14 +--- .../kotlin/su/reya/coop/Screens.kt | 16 ----- .../su/reya/coop/storage/SecretStore.kt | 17 ++--- shared/build.gradle.kts | 2 + .../kotlin/su/reya/coop/Greeting.kt | 9 --- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 4 ++ .../kotlin/su/reya/coop/NostrViewModel.kt | 44 ++++++++++++ .../kotlin/su/reya/coop/Platform.kt | 7 -- .../su/reya/coop/storage/SecretStorage.kt | 8 +++ 11 files changed, 100 insertions(+), 94 deletions(-) delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/Greeting.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/Platform.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/storage/SecretStorage.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 1db42c0..2a03a37 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -23,6 +23,7 @@ kotlin { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0") implementation("androidx.datastore:datastore-preferences:1.2.1") implementation("androidx.datastore:datastore-preferences-core:1.2.1") + implementation("org.jetbrains.compose.material3:material3*:1.10.0-alpha05") } commonMain.dependencies { implementation(libs.compose.runtime) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 42222e4..f9aeb8f 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -1,72 +1,53 @@ package su.reya.coop -import android.content.Context import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +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.ui.platform.LocalContext -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.preferencesDataStore +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch - -private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") -private val FIRST_TIME_KEY = booleanPreferencesKey("first_time") +import kotlinx.coroutines.flow.flow +import su.reya.coop.coop.storage.SecretStore @Composable -fun App() { +fun App(dbPath: String) { + val context = LocalContext.current + val nostr = remember { Nostr() } + val secretStore = remember { SecretStore(context) } + val viewModel: NostrViewModel = viewModel { NostrViewModel(nostr, secretStore) } + + LaunchedEffect(Unit) { + viewModel.initAndConnect(dbPath) + } + MaterialTheme { - val context = LocalContext.current - val scope = rememberCoroutineScope() + rememberCoroutineScope() val navController = rememberNavController() - val isFirstTimeFlow = remember { - context.dataStore.data.map { preferences -> - preferences[FIRST_TIME_KEY] ?: true + // Get user's signer status + val hasSecretFlow = remember { + flow { + emit(secretStore.has("user_signer")) } } - val isFirstTime by isFirstTimeFlow.collectAsState(initial = null) + val hasSecret by hasSecretFlow.collectAsState(initial = null) - if (isFirstTime == null) { + if (hasSecret == null) { // Loading state return@MaterialTheme } NavHost( navController = navController, - startDestination = if (isFirstTime == true) Screen.Welcome else Screen.Home + startDestination = if (hasSecret == true) Screen.Onboarding else Screen.Home ) { - composable { backStackEntry -> - WelcomeScreen(onContinue = { - scope.launch { - context.dataStore.edit { settings -> - settings[FIRST_TIME_KEY] = false - } - navController.navigate(Screen.Home) { - popUpTo { inclusive = true } - } - } - }) - } - composable { backStackEntry -> - HomeScreen( - onOpenChat = { id -> navController.navigate(Screen.Chat(id)) } - ) - } - composable { backStackEntry -> - val chat: Screen.Chat = backStackEntry.toRoute() - ChatScreen(id = chat.id) - } composable { backStackEntry -> OnboardingScreen( onOpenImport = { navController.navigate(Screen.Import) }, @@ -79,6 +60,15 @@ fun App() { composable { backStackEntry -> NewScreen() } + composable { backStackEntry -> + HomeScreen( + onOpenChat = { id -> navController.navigate(Screen.Chat(id)) } + ) + } + composable { backStackEntry -> + val chat: Screen.Chat = backStackEntry.toRoute() + ChatScreen(id = chat.id) + } } } } \ 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 2ba9751..d2240f7 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -6,13 +6,9 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.launch import java.io.File class MainActivity : ComponentActivity() { - private val nostr = Nostr() - override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -21,16 +17,8 @@ class MainActivity : ComponentActivity() { val dbDir = File(filesDir, "nostr") dbDir.mkdirs() - // Initialize nostr client - nostr.init(dbDir.absolutePath) - - // Connect to bootstrap relays - lifecycleScope.launch { - nostr.connect() - } - setContent { - App() + App(dbDir.absolutePath) } } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Screens.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Screens.kt index 47ff239..fdf32ad 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/Screens.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Screens.kt @@ -14,9 +14,6 @@ import androidx.compose.ui.unit.dp import kotlinx.serialization.Serializable sealed interface Screen { - @Serializable - data object Welcome : Screen - @Serializable data object Home : Screen @@ -33,19 +30,6 @@ sealed interface Screen { data object New : Screen } -@Composable -fun WelcomeScreen(onContinue: () -> Unit) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("Welcome Screen") - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = onContinue) { - Text("Get Started") - } - } - } -} - @Composable fun HomeScreen(onOpenChat: (String) -> Unit) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretStore.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretStore.kt index e376167..37ffc47 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretStore.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretStore.kt @@ -5,13 +5,14 @@ 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) { +class SecretStore(private val context: Context) : SecretStorage { private val crypto = SecretCrypto() - suspend fun set(key: String, value: String) { + override suspend fun set(key: String, value: String) { val entry = crypto.encrypt(value) context.dataStore.edit { prefs -> @@ -20,7 +21,7 @@ class SecretStore(private val context: Context) { } } - suspend fun get(key: String): String? { + 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 @@ -28,15 +29,15 @@ class SecretStore(private val context: Context) { return crypto.decrypt(SecretEntry(encrypted, iv)) } - suspend fun clear(name: String) { + override suspend fun clear(key: String) { context.dataStore.edit { prefs -> - prefs.remove(stringPreferencesKey("${name}_encrypted")) - prefs.remove(stringPreferencesKey("${name}_iv")) + prefs.remove(stringPreferencesKey("${key}_encrypted")) + prefs.remove(stringPreferencesKey("${key}_iv")) } } - suspend fun has(name: String): Boolean { + override suspend fun has(key: String): Boolean { val prefs = context.dataStore.data.first() - return prefs[stringPreferencesKey("${name}_encrypted")] != null + return prefs[stringPreferencesKey("${key}_encrypted")] != null } } \ No newline at end of file diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 84dfc7b..c410d30 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -24,6 +24,8 @@ kotlin { sourceSets { commonMain.dependencies { + implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") implementation("org.rust-nostr:nostr-sdk-kmp:0.44.3") } commonTest.dependencies { 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 index 360aa97..95777ca 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -25,4 +25,8 @@ class Nostr { this.client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) this.client?.connect() } + + suspend fun disconnect() { + this.client?.shutdown() + } } 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..c56f4b0 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -0,0 +1,44 @@ +package su.reya.coop + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import su.reya.coop.storage.SecretStorage + +class NostrViewModel( + private val nostr: Nostr, + private val secretStore: SecretStorage +) : ViewModel() { + private val _isConnected = MutableStateFlow(false) + val isConnected = _isConnected.asStateFlow() + + fun initAndConnect(dbPath: String) { + // Initialize nostr client + nostr.init(dbPath) + + viewModelScope.launch { + try { + // Connect to bootstrap relays + nostr.connect() + _isConnected.value = true + } catch (e: Exception) { + _isConnected.value = false + println(e) + } + } + } + + override fun onCleared() { + super.onCleared() + // Ensure all relays are disconnect + viewModelScope.launch { + withContext(NonCancellable) { + nostr.disconnect() + } + } + } +} \ No newline at end of file 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/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 -- 2.49.1 From feffda519f2304ded23adfacc46fdd3f7e0c4267 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 27 Apr 2026 12:14:54 +0700 Subject: [PATCH 05/43] add simple create identity flow --- composeApp/build.gradle.kts | 4 +- .../androidMain/kotlin/su/reya/coop/App.kt | 29 +++-- .../kotlin/su/reya/coop/MainActivity.kt | 8 -- .../kotlin/su/reya/coop/Navigation.kt | 20 +++ .../kotlin/su/reya/coop/Screens.kt | 82 ------------ .../kotlin/su/reya/coop/screens/ChatScreen.kt | 15 +++ .../kotlin/su/reya/coop/screens/HomeScreen.kt | 26 ++++ .../su/reya/coop/screens/ImportScreen.kt | 15 +++ .../su/reya/coop/screens/NewIdentityScreen.kt | 118 ++++++++++++++++++ .../su/reya/coop/screens/OnboardingScreen.kt | 30 +++++ .../kotlin/su/reya/coop/Platform.android.kt | 9 -- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 30 +++++ .../kotlin/su/reya/coop/NostrViewModel.kt | 22 ++++ .../kotlin/su/reya/coop/Platform.ios.kt | 9 -- 14 files changed, 301 insertions(+), 116 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt delete mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/Screens.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt delete mode 100644 shared/src/androidMain/kotlin/su/reya/coop/Platform.android.kt delete mode 100644 shared/src/iosMain/kotlin/su/reya/coop/Platform.ios.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 2a03a37..08b3b24 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -23,7 +23,9 @@ kotlin { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0") implementation("androidx.datastore:datastore-preferences:1.2.1") implementation("androidx.datastore:datastore-preferences-core:1.2.1") - implementation("org.jetbrains.compose.material3:material3*:1.10.0-alpha05") + 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") } commonMain.dependencies { implementation(libs.compose.runtime) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index f9aeb8f..a34d159 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -1,6 +1,7 @@ package su.reya.coop -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -15,7 +16,13 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute import kotlinx.coroutines.flow.flow 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.NewIdentityScreen +import su.reya.coop.screens.OnboardingScreen +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun App(dbPath: String) { val context = LocalContext.current @@ -27,7 +34,7 @@ fun App(dbPath: String) { viewModel.initAndConnect(dbPath) } - MaterialTheme { + MaterialExpressiveTheme { rememberCoroutineScope() val navController = rememberNavController() @@ -41,24 +48,32 @@ fun App(dbPath: String) { if (hasSecret == null) { // Loading state - return@MaterialTheme + return@MaterialExpressiveTheme } NavHost( navController = navController, - startDestination = if (hasSecret == true) Screen.Onboarding else Screen.Home + startDestination = if (hasSecret == true) Screen.Home else Screen.Onboarding ) { composable { backStackEntry -> OnboardingScreen( onOpenImport = { navController.navigate(Screen.Import) }, - onOpenNew = { navController.navigate(Screen.New) } + onOpenNew = { navController.navigate(Screen.NewIdentity) } ) } composable { backStackEntry -> ImportScreen() } - composable { backStackEntry -> - NewScreen() + composable { backStackEntry -> + val isCreating by viewModel.isCreating.collectAsState() + + NewIdentityScreen( + isLoading = isCreating, + onSave = { name, bio, uri -> + viewModel.createIdentity(name, bio, uri?.toString()) + navController.navigate(Screen.Home) + } + ) } composable { backStackEntry -> HomeScreen( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index d2240f7..4d00430 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -4,8 +4,6 @@ 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 import java.io.File class MainActivity : ComponentActivity() { @@ -22,9 +20,3 @@ class MainActivity : ComponentActivity() { } } } - -@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..e08a11c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt @@ -0,0 +1,20 @@ +package su.reya.coop + +import kotlinx.serialization.Serializable + +sealed interface Screen { + @Serializable + data object Home : Screen + + @Serializable + data class Chat(val id: String) : Screen + + @Serializable + data object Onboarding : Screen + + @Serializable + data object Import : Screen + + @Serializable + data object NewIdentity : Screen +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Screens.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Screens.kt deleted file mode 100644 index fdf32ad..0000000 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/Screens.kt +++ /dev/null @@ -1,82 +0,0 @@ -package su.reya.coop - -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.height -import androidx.compose.material3.Button -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import kotlinx.serialization.Serializable - -sealed interface Screen { - @Serializable - data object Home : Screen - - @Serializable - data class Chat(val id: String) : Screen - - @Serializable - data object Onboarding : Screen - - @Serializable - data object Import : Screen - - @Serializable - data object New : Screen -} - -@Composable -fun HomeScreen(onOpenChat: (String) -> Unit) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("Home Screen") - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { onOpenChat("123") }) { - Text("Open Chat 123") - } - } - } -} - -@Composable -fun ChatScreen(id: String) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Chat Screen (ID: $id)") - } -} - -@Composable -fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("Onboarding Screen") - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = onOpenImport) { - Text("Import") - } - Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = onOpenNew) { - Text("New") - } - } - } -} - -@Composable -fun ImportScreen() { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Import Screen") - } -} - -@Composable -fun NewScreen() { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("New Screen") - } -} 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..5677e4b --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -0,0 +1,15 @@ +package su.reya.coop.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun ChatScreen(id: String) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Chat Screen (ID: $id)") + } +} 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..ad47bfb --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -0,0 +1,26 @@ +package su.reya.coop.screens + +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.height +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun HomeScreen(onOpenChat: (String) -> Unit) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Home Screen") + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { onOpenChat("123") }) { + Text("Open Chat 123") + } + } + } +} 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..10526dd --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -0,0 +1,15 @@ +package su.reya.coop.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun ImportScreen() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Import Screen") + } +} 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..4486217 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -0,0 +1,118 @@ +package su.reya.coop.screens + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +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.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun NewIdentityScreen( + isLoading: Boolean, + onSave: (name: String, bio: String, picture: Uri?) -> Unit +) { + 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 + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "New Identity", + style = MaterialTheme.typography.headlineMediumEmphasized + ) + Box( + modifier = Modifier + .size(120.dp) + .clip(CircleShape), + 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() + + ) { + // + } + } + } + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = bio, + onValueChange = { bio = it }, + label = { Text("Bio:") }, + modifier = Modifier + .fillMaxWidth() + .height(150.dp), + minLines = 3, + ) + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = { + onSave(name, bio, picture) + }, + modifier = Modifier.fillMaxWidth(), + enabled = name.isNotBlank() && !isLoading, + ) { + if (isLoading) { + LoadingIndicator() + } else { + Text("Save & Continue") + } + } + } +} 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..2f9d7f0 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -0,0 +1,30 @@ +package su.reya.coop.screens + +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.height +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Onboarding Screen") + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onOpenImport) { + Text("Import") + } + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = onOpenNew) { + Text("New") + } + } + } +} 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/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 95777ca..48d52ca 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -3,13 +3,22 @@ package su.reya.coop import rust.nostr.sdk.Client import rust.nostr.sdk.ClientBuilder import rust.nostr.sdk.ClientOptions +import rust.nostr.sdk.EventBuilder +import rust.nostr.sdk.Keys +import rust.nostr.sdk.Metadata +import rust.nostr.sdk.MetadataRecord import rust.nostr.sdk.NostrDatabase import rust.nostr.sdk.NostrGossip +import rust.nostr.sdk.NostrSigner import rust.nostr.sdk.RelayUrl class Nostr { var client: Client? = null private set + var signer: NostrSigner? = null + private set + var deviceSigner: NostrSigner? = null + private set fun init(dbPath: String) { val lmdb = NostrDatabase.lmdb(dbPath) @@ -29,4 +38,25 @@ class Nostr { suspend fun disconnect() { this.client?.shutdown() } + + suspend fun createIdentity(keys: Keys, name: String, bio: String, picture: String?) { + signer = NostrSigner.keys(keys) + + // Construct metadata + val metadata = Metadata.fromRecord( + MetadataRecord( + name = name, + displayName = name, + about = bio, + picture = picture + ) + ) + + // Construct event and sign it + val builder = EventBuilder.metadata(metadata).build(keys.publicKey()) + val event = this.signer?.signEvent(builder) ?: return + + // Send event to relays + this.client?.sendEvent(event) + } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index c56f4b0..5ba5358 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import rust.nostr.sdk.Keys import su.reya.coop.storage.SecretStorage class NostrViewModel( @@ -16,6 +17,9 @@ class NostrViewModel( private val _isConnected = MutableStateFlow(false) val isConnected = _isConnected.asStateFlow() + private val _isCreating = MutableStateFlow(false) + val isCreating = _isCreating.asStateFlow() + fun initAndConnect(dbPath: String) { // Initialize nostr client nostr.init(dbPath) @@ -32,6 +36,24 @@ class NostrViewModel( } } + fun createIdentity(name: String, bio: String, picture: String?) { + viewModelScope.launch { + try { + val keys = Keys.generate() + val secret = keys.secretKey().toBech32() + // Set loading state + _isCreating.value = true + // Create identity + nostr.createIdentity(keys, name, bio, picture) + // Save secret to the secret storage + secretStore.set("user_signer", secret) + } catch (e: Exception) { + _isCreating.value = false + println(e) + } + } + } + override fun onCleared() { super.onCleared() // Ensure all relays are disconnect 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 -- 2.49.1 From 76050b541012b3ba19735ae389683b0e609b6e00 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 28 Apr 2026 09:35:33 +0700 Subject: [PATCH 06/43] simple nostr service --- .../androidMain/kotlin/su/reya/coop/App.kt | 24 ++++--- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 71 ++++++++++++++++--- .../kotlin/su/reya/coop/NostrViewModel.kt | 63 ++++++++++++++-- 3 files changed, 129 insertions(+), 29 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index a34d159..5ab4604 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -14,7 +14,6 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute -import kotlinx.coroutines.flow.flow import su.reya.coop.coop.storage.SecretStore import su.reya.coop.screens.ChatScreen import su.reya.coop.screens.HomeScreen @@ -37,19 +36,23 @@ fun App(dbPath: String) { MaterialExpressiveTheme { rememberCoroutineScope() val navController = rememberNavController() + val hasSecret by viewModel.hasSecret.collectAsState(initial = null) - // Get user's signer status - val hasSecretFlow = remember { - flow { - emit(secretStore.has("user_signer")) + LaunchedEffect(hasSecret) { + // Navigate to the home screen if the secret is already set + if (hasSecret == true) { + // Start a background notification handler + viewModel.startNotificationHandler() + + // Navigate to the home screen + navController.navigate(Screen.Home) { + popUpTo(Screen.Onboarding) { inclusive = true } + } } } - val hasSecret by hasSecretFlow.collectAsState(initial = null) - if (hasSecret == null) { - // Loading state - return@MaterialExpressiveTheme - } + // Show loading screen while initializing + if (hasSecret == null) return@MaterialExpressiveTheme NavHost( navController = navController, @@ -71,7 +74,6 @@ fun App(dbPath: String) { isLoading = isCreating, onSave = { name, bio, uri -> viewModel.createIdentity(name, bio, uri?.toString()) - navController.navigate(Screen.Home) } ) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 48d52ca..0dd2978 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -3,22 +3,30 @@ package su.reya.coop import rust.nostr.sdk.Client import rust.nostr.sdk.ClientBuilder import rust.nostr.sdk.ClientOptions +import rust.nostr.sdk.Event import rust.nostr.sdk.EventBuilder +import rust.nostr.sdk.Filter +import rust.nostr.sdk.HandleNotification import rust.nostr.sdk.Keys +import rust.nostr.sdk.Kind +import rust.nostr.sdk.KindStandard import rust.nostr.sdk.Metadata import rust.nostr.sdk.MetadataRecord +import rust.nostr.sdk.NostrConnect import rust.nostr.sdk.NostrDatabase import rust.nostr.sdk.NostrGossip import rust.nostr.sdk.NostrSigner +import rust.nostr.sdk.RelayMessage import rust.nostr.sdk.RelayUrl +import rust.nostr.sdk.ReqExitPolicy +import rust.nostr.sdk.SubscribeAutoCloseOptions +import rust.nostr.sdk.Timestamp class Nostr { var client: Client? = null private set var signer: NostrSigner? = null private set - var deviceSigner: NostrSigner? = null - private set fun init(dbPath: String) { val lmdb = NostrDatabase.lmdb(dbPath) @@ -39,20 +47,61 @@ class Nostr { this.client?.shutdown() } - suspend fun createIdentity(keys: Keys, name: String, bio: String, picture: String?) { + suspend fun setKeySigner(keys: Keys) { signer = NostrSigner.keys(keys) + this.getMetadata() + } - // Construct metadata - val metadata = Metadata.fromRecord( - MetadataRecord( - name = name, - displayName = name, - about = bio, - picture = picture + suspend fun setRemoteSigner(signer: NostrConnect) { + this.signer = NostrSigner.nostrConnect(signer) + this.getMetadata() + } + + suspend fun getMetadata() { + val currentUserPubKey = this.signer?.getPublicKey() ?: return + val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) + val filter = Filter().author(currentUserPubKey).limit(10u).kinds( + listOf( + Kind.fromStd(KindStandard.METADATA), + Kind.fromStd(KindStandard.CONTACT_LIST), + Kind.fromStd(KindStandard.INBOX_RELAYS) ) ) - // Construct event and sign it + this.client?.subscribe(filter, opts) + } + + suspend fun handleNotifications() { + val now = Timestamp.now() + + this.client?.handleNotifications(object : HandleNotification { + override suspend fun handle(relayUrl: RelayUrl, subscriptionId: String, event: Event) { + TODO("Not yet implemented") + } + + override suspend fun handleMsg( + relayUrl: RelayUrl, + msg: RelayMessage + ) { + TODO("Not yet implemented") + } + }) + } + + suspend fun createIdentity(keys: Keys, name: String, bio: String, picture: String?) { + // Set signer + signer = NostrSigner.keys(keys) + + // Construct metadata records + val records = MetadataRecord( + name = name, + displayName = name, + about = bio, + picture = picture + ) + + // Construct a nostr event and sign it + val metadata = Metadata.fromRecord(records) val builder = EventBuilder.metadata(metadata).build(keys.publicKey()) val event = this.signer?.signEvent(builder) ?: return diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 5ba5358..d83a7ed 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -8,14 +8,17 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import rust.nostr.sdk.Keys +import rust.nostr.sdk.NostrConnect +import rust.nostr.sdk.NostrConnectUri import su.reya.coop.storage.SecretStorage +import kotlin.time.Duration class NostrViewModel( private val nostr: Nostr, private val secretStore: SecretStorage ) : ViewModel() { - private val _isConnected = MutableStateFlow(false) - val isConnected = _isConnected.asStateFlow() + private val _hasSecret = MutableStateFlow(null) + val hasSecret = _hasSecret.asStateFlow() private val _isCreating = MutableStateFlow(false) val isCreating = _isCreating.asStateFlow() @@ -28,14 +31,61 @@ class NostrViewModel( try { // Connect to bootstrap relays nostr.connect() - _isConnected.value = true + + // Get user's signer secret + val secret = secretStore.get("user_signer") + + // If no secret is found, show onboarding screen + if (secret == null) { + _hasSecret.value = false + return@launch + } + _hasSecret.value = true + + // Handle different signer types + if (secret.startsWith("nsec1")) { + val keys = Keys.parse(secret) + nostr.setKeySigner(keys) + } else if (secret.startsWith("bunker://")) { + val appKeys = getOrInitAppKeys() + val bunker = NostrConnectUri.parse(secret) + val remote = NostrConnect( + uri = bunker, + appKeys = appKeys, + timeout = Duration.parse("5"), + opts = null + ) + nostr.setRemoteSigner(remote) + } else { + throw IllegalArgumentException("Invalid secret format: $secret") + } } catch (e: Exception) { - _isConnected.value = false - println(e) + println("Failed to connect: ${e.message}") } } } + fun startNotificationHandler() { + viewModelScope.launch { + nostr.handleNotifications() + } + } + + 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: String?) { viewModelScope.launch { try { @@ -48,8 +98,7 @@ class NostrViewModel( // Save secret to the secret storage secretStore.set("user_signer", secret) } catch (e: Exception) { - _isCreating.value = false - println(e) + println("Create identity failed: $e") } } } -- 2.49.1 From 3376e71bdaa6d3672019e59e04c0f1c2c6fb618e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 1 May 2026 09:13:34 +0700 Subject: [PATCH 07/43] use custom nostr sdk --- shared/build.gradle.kts | 2 +- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 166 +++++++++++++----- .../kotlin/su/reya/coop/NostrViewModel.kt | 6 +- 3 files changed, 126 insertions(+), 48 deletions(-) diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index c410d30..752c2c6 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -26,7 +26,7 @@ kotlin { commonMain.dependencies { implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") - implementation("org.rust-nostr:nostr-sdk-kmp:0.44.3") + implementation("su.reya:nostr-sdk-kmp:0.1") } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 0dd2978..80c3cfc 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -2,11 +2,11 @@ package su.reya.coop import rust.nostr.sdk.Client import rust.nostr.sdk.ClientBuilder -import rust.nostr.sdk.ClientOptions -import rust.nostr.sdk.Event +import rust.nostr.sdk.ClientNotification +import rust.nostr.sdk.Contact import rust.nostr.sdk.EventBuilder import rust.nostr.sdk.Filter -import rust.nostr.sdk.HandleNotification +import rust.nostr.sdk.GossipConfig import rust.nostr.sdk.Keys import rust.nostr.sdk.Kind import rust.nostr.sdk.KindStandard @@ -16,9 +16,12 @@ import rust.nostr.sdk.NostrConnect import rust.nostr.sdk.NostrDatabase import rust.nostr.sdk.NostrGossip import rust.nostr.sdk.NostrSigner -import rust.nostr.sdk.RelayMessage +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayCapabilities +import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.ReqExitPolicy +import rust.nostr.sdk.ReqTarget import rust.nostr.sdk.SubscribeAutoCloseOptions import rust.nostr.sdk.Timestamp @@ -28,39 +31,59 @@ class Nostr { var signer: NostrSigner? = null private set - fun init(dbPath: String) { + suspend fun init(dbPath: String) { val lmdb = NostrDatabase.lmdb(dbPath) val gossip = NostrGossip.inMemory() - val opts = ClientOptions().automaticAuthentication(false) - client = ClientBuilder().database(lmdb).gossip(gossip).opts(opts).build() + client = + ClientBuilder() + .database(lmdb) + .gossip(gossip) + .gossipConfig(GossipConfig().noBackgroundRefresh()) + .maxRelays(20u) + .verifySubscriptions(false) + .automaticAuthentication(false) + .build() } suspend fun connect() { - this.client?.addRelay(RelayUrl.parse("wss://relay.damus.io")) - this.client?.addRelay(RelayUrl.parse("wss://relay.primal.net")) - this.client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) - this.client?.connect() + client?.addRelay( + url = RelayUrl.parse("wss://relay.damus.io"), + capabilities = RelayCapabilities.none() + ) + client?.addRelay( + url = RelayUrl.parse("wss://relay.primal.net"), + capabilities = RelayCapabilities.none() + ) + client?.addRelay( + url = RelayUrl.parse("wss://user.kindpag.es"), + capabilities = RelayCapabilities.none() + ) + client?.addRelay( + url = RelayUrl.parse("https://indexer.coracle.social"), + capabilities = RelayCapabilities.gossip() + ) + client?.connect() } suspend fun disconnect() { - this.client?.shutdown() + client?.shutdown() } suspend fun setKeySigner(keys: Keys) { signer = NostrSigner.keys(keys) - this.getMetadata() + getUserMetadata() } - suspend fun setRemoteSigner(signer: NostrConnect) { - this.signer = NostrSigner.nostrConnect(signer) - this.getMetadata() + suspend fun setRemoteSigner(remote: NostrConnect) { + signer = NostrSigner.nostrConnect(remote) + getUserMetadata() } - suspend fun getMetadata() { - val currentUserPubKey = this.signer?.getPublicKey() ?: return - val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) - val filter = Filter().author(currentUserPubKey).limit(10u).kinds( + suspend fun getUserMetadata() { + val userPubkey = signer?.getPublicKey() ?: return + + val filter = Filter().author(userPubkey).limit(10u).kinds( listOf( Kind.fromStd(KindStandard.METADATA), Kind.fromStd(KindStandard.CONTACT_LIST), @@ -68,44 +91,99 @@ class Nostr { ) ) - this.client?.subscribe(filter, opts) + val target = ReqTarget.auto(listOf(filter)) + val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) + + client?.subscribe(target = target, id = "user-metadata", closeOn = opts) } suspend fun handleNotifications() { val now = Timestamp.now() + val notifications = client?.notifications() - this.client?.handleNotifications(object : HandleNotification { - override suspend fun handle(relayUrl: RelayUrl, subscriptionId: String, event: Event) { - TODO("Not yet implemented") - } + while (true) { + val notification = notifications?.next() ?: break - override suspend fun handleMsg( - relayUrl: RelayUrl, - msg: RelayMessage - ) { - TODO("Not yet implemented") + when (notification) { + is ClientNotification.Message -> { + // TODO: Handle message + } + + is ClientNotification.NewEvent -> { + // TODO: Handle new event + } + + is ClientNotification.Shutdown -> { + break + } } - }) + } + } + + 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 getMsgRelayList(): 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?) { // Set signer signer = NostrSigner.keys(keys) - // Construct metadata records - val records = MetadataRecord( - name = name, - displayName = name, - about = bio, - picture = picture - ) + // Send relay list event + val relayList = getDefaultRelayList() + val relayListEvent = EventBuilder.relayList(relayList).sign(signer!!); + client?.sendEvent(relayListEvent) - // Construct a nostr event and sign it - val metadata = Metadata.fromRecord(records) - val builder = EventBuilder.metadata(metadata).build(keys.publicKey()) - val event = this.signer?.signEvent(builder) ?: return + // Send messaging relay list event + val msgRelayList = getMsgRelayList() + val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).sign(signer!!) + client?.sendEventNoWait(msgRelayListEvent) - // Send event to relays - this.client?.sendEvent(event) + // Send metadata event + val metadata = + Metadata.fromRecord(MetadataRecord(name = name, about = bio, picture = picture)) + val metadataEvent = EventBuilder.metadata(metadata).sign(signer!!) + client?.sendEventNoWait(metadataEvent) + + // Send contact list event + val defaultContact = + listOf(Contact(publicKey = PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x"))) + val contactListEvent = EventBuilder.contactList(defaultContact).sign(signer!!) + client?.sendEventNoWait(contactListEvent) } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index d83a7ed..ff50dff 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -24,11 +24,11 @@ class NostrViewModel( val isCreating = _isCreating.asStateFlow() fun initAndConnect(dbPath: String) { - // Initialize nostr client - nostr.init(dbPath) - viewModelScope.launch { try { + // Initialize nostr client + nostr.init(dbPath) + // Connect to bootstrap relays nostr.connect() -- 2.49.1 From 439391ff6e26923bae5df63cb6c84db02a49bb35 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 2 May 2026 09:03:30 +0700 Subject: [PATCH 08/43] add extract rumor --- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 135 ++++++++++++++++-- .../kotlin/su/reya/coop/NostrViewModel.kt | 18 ++- 2 files changed, 142 insertions(+), 11 deletions(-) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 80c3cfc..e2c4a35 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -4,7 +4,9 @@ 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 @@ -18,18 +20,24 @@ import rust.nostr.sdk.NostrGossip import rust.nostr.sdk.NostrSigner 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.SubscribeAutoCloseOptions +import rust.nostr.sdk.Tag import rust.nostr.sdk.Timestamp +import rust.nostr.sdk.UnsignedEvent +import rust.nostr.sdk.UnwrappedGift class Nostr { var client: Client? = null private set var signer: NostrSigner? = null private set + var deviceSigner: NostrSigner? = null + private set suspend fun init(dbPath: String) { val lmdb = NostrDatabase.lmdb(dbPath) @@ -83,21 +91,26 @@ class Nostr { suspend fun getUserMetadata() { val userPubkey = signer?.getPublicKey() ?: return - val filter = Filter().author(userPubkey).limit(10u).kinds( - listOf( - Kind.fromStd(KindStandard.METADATA), - Kind.fromStd(KindStandard.CONTACT_LIST), - Kind.fromStd(KindStandard.INBOX_RELAYS) - ) - ) + // Get the latest metadata event + val metadataFilter = + Filter().author(userPubkey).limit(1u).kind(Kind.fromStd(KindStandard.METADATA)) - val target = ReqTarget.auto(listOf(filter)) + // Get the latest contact list event + val contactFilter = + Filter().author(userPubkey).limit(1u).kind(Kind.fromStd(KindStandard.CONTACT_LIST)) + + // Get the latest messaging relay list event + val msgRelayFilter = + Filter().author(userPubkey).limit(1u).kind(Kind.fromStd(KindStandard.INBOX_RELAYS)) + + // 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) } - suspend fun handleNotifications() { + suspend fun handleNotifications(onMetadataUpdate: (PublicKey, Metadata) -> Unit) { val now = Timestamp.now() val notifications = client?.notifications() @@ -106,7 +119,42 @@ class Nostr { when (notification) { is ClientNotification.Message -> { - // TODO: Handle message + val relayUrl = notification.relayUrl + val message = notification.message.asEnum() + + when (message) { + is RelayMessageEnum.EventMsg -> { + val event = message.event + + + if (event.kind().asStd() == KindStandard.METADATA) { + try { + val metadata = Metadata.fromJson(event.content()) + onMetadataUpdate(event.author(), metadata) + } catch (e: Exception) { + println("Failed to parse metadata: $e") + } + } + + if (event.kind().asStd() == KindStandard.GIFT_WRAP) { + try { + val rumor = extractRumor(event) + // TODO: Handle rumor + } catch (e: Exception) { + println("Failed to extract rumor: $e") + } + } + } + + is RelayMessageEnum.EndOfStoredEvents -> { + val subscriptionId = message.subscriptionId + // TODO: Handle end of stored events + } + + else -> { + /* Ignore other message types */ + } + } } is ClientNotification.NewEvent -> { @@ -120,6 +168,73 @@ class Nostr { } } + suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? { + try { + val filter = Filter().identifier(giftId.toBech32()) + val event = client?.database()?.query(filter)?.first() + + return event?.content()?.let { UnsignedEvent.fromJson(it) } + } catch (e: Exception) { + // TODO: log error + } + return null + } + + suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { + if (rumor.id() == null) return + try { + val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA); + val tags = listOf(Tag.identifier(giftId.toBech32()), Tag.event(rumor.id()!!)) + val event = EventBuilder(kind, rumor.asJson()).tags(tags).signWithKeys(Keys.generate()) + + client?.database()?.saveEvent(event) + } catch (e: Exception) { + // TODO: log error + } + } + + suspend fun extractRumor(event: Event): UnsignedEvent? { + if (event.kind().asStd() != KindStandard.GIFT_WRAP) return null + + // 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.fromGiftWrap(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) { + // TODO: log error + continue + } + } + + return null + } + + fun conversationId(rumor: UnsignedEvent): Long { + val pubkeys: MutableList = rumor.tags().publicKeys().toMutableList() + pubkeys.add(rumor.author()) + + val uniqueSortedKeys = pubkeys + .map { it.toHex() } + .distinct() + .sorted() + + return uniqueSortedKeys.hashCode().toLong() + } + suspend fun getDefaultRelayList(): Map { // Construct a list of relays val relayList = mapOf( diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index ff50dff..e6e5361 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -4,12 +4,15 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext 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 su.reya.coop.storage.SecretStorage import kotlin.time.Duration @@ -23,6 +26,17 @@ class NostrViewModel( private val _isCreating = MutableStateFlow(false) val isCreating = _isCreating.asStateFlow() + // User metadata store + private val _metadataStore = mutableMapOf>() + + fun getMetadata(pubkey: PublicKey): StateFlow { + return _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.asStateFlow() + } + + fun updateMetadata(pubkey: PublicKey, metadata: Metadata) { + _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata + } + fun initAndConnect(dbPath: String) { viewModelScope.launch { try { @@ -67,7 +81,9 @@ class NostrViewModel( fun startNotificationHandler() { viewModelScope.launch { - nostr.handleNotifications() + nostr.handleNotifications { pubkey, metadata -> + updateMetadata(pubkey, metadata) + } } } -- 2.49.1 From 77f4ea71b1f2c68be38379d6383eee4b63d3a2bd Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 2 May 2026 18:03:12 +0700 Subject: [PATCH 09/43] add get user messages --- shared/build.gradle.kts | 2 +- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 752c2c6..9c7d9b2 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -26,7 +26,7 @@ kotlin { commonMain.dependencies { implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") - implementation("su.reya:nostr-sdk-kmp:0.1") + implementation("su.reya:nostr-sdk-kmp:0.1.1") } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index e2c4a35..42d6bb4 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -30,6 +30,7 @@ import rust.nostr.sdk.Tag import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnwrappedGift +import rust.nostr.sdk.extractMessagingRelayList class Nostr { var client: Client? = null @@ -88,6 +89,14 @@ class Nostr { getUserMetadata() } + suspend fun isSignedByUser(event: Event): Boolean { + return try { + signer?.getPublicKey()?.toBech32() == event.author().toBech32() + } catch (e: Exception) { + false + } + } + suspend fun getUserMetadata() { val userPubkey = signer?.getPublicKey() ?: return @@ -110,9 +119,34 @@ class Nostr { client?.subscribe(target = target, id = "user-metadata", closeOn = opts) } + suspend fun getUserMessages(msgRelayList: Event) { + val userPubkey = signer?.getPublicKey() ?: return + val relays = extractMessagingRelayList(msgRelayList) + + // Ensure relay connections + relays.forEach { relay -> + client?.addRelay(relay, RelayCapabilities.none()) + client?.connectRelay(relay) + } + + // Construct a filter for gift wrap events + val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(userPubkey) + val target = mutableMapOf>() + relays.forEach { relay -> + target[relay] = listOf(filter) + } + + client?.subscribe( + target = ReqTarget.manual(target), + id = "user-messages", + closeOn = null + ) + } + suspend fun handleNotifications(onMetadataUpdate: (PublicKey, Metadata) -> Unit) { val now = Timestamp.now() val notifications = client?.notifications() + val processedEvent = mutableSetOf() while (true) { val notification = notifications?.next() ?: break @@ -126,6 +160,9 @@ class Nostr { is RelayMessageEnum.EventMsg -> { val event = message.event + // Prevent processing duplicate events + if (processedEvent.contains(event.id())) continue + processedEvent.add(event.id()) if (event.kind().asStd() == KindStandard.METADATA) { try { @@ -136,6 +173,12 @@ class Nostr { } } + if (event.kind().asStd() == KindStandard.INBOX_RELAYS) { + if (isSignedByUser(event = event)) { + getUserMessages(msgRelayList = event) + } + } + if (event.kind().asStd() == KindStandard.GIFT_WRAP) { try { val rumor = extractRumor(event) -- 2.49.1 From e02338fd5229292e832b9560312c867f49e9d445 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 3 May 2026 09:12:43 +0700 Subject: [PATCH 10/43] basic home screen --- .../androidMain/kotlin/su/reya/coop/App.kt | 28 +++++++- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 71 +++++++++++++++---- .../su/reya/coop/screens/ImportScreen.kt | 56 +++++++++++++-- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 34 ++++----- .../kotlin/su/reya/coop/NostrViewModel.kt | 6 +- 5 files changed, 159 insertions(+), 36 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 5ab4604..5cfac06 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -1,7 +1,12 @@ package su.reya.coop +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -29,11 +34,23 @@ fun App(dbPath: String) { val secretStore = remember { SecretStore(context) } val viewModel: NostrViewModel = viewModel { NostrViewModel(nostr, secretStore) } + val darkMode = isSystemInDarkTheme() + 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 -> lightColorScheme() + } + LaunchedEffect(Unit) { viewModel.initAndConnect(dbPath) } - MaterialExpressiveTheme { + MaterialExpressiveTheme( + colorScheme = colorScheme, + ) { rememberCoroutineScope() val navController = rememberNavController() val hasSecret by viewModel.hasSecret.collectAsState(initial = null) @@ -65,7 +82,14 @@ fun App(dbPath: String) { ) } composable { backStackEntry -> - ImportScreen() + val isCreating by viewModel.isCreating.collectAsState() + + ImportScreen( + isLoading = isCreating, + onSave = { secret -> + viewModel.import(secret) + } + ) } composable { backStackEntry -> val isCreating by viewModel.isCreating.collectAsState() diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index ad47bfb..7960316 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -1,26 +1,73 @@ package su.reya.coop.screens 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.material3.Button +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.AppBarWithSearch +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text +import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun HomeScreen(onOpenChat: (String) -> Unit) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("Home Screen") - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { onOpenChat("123") }) { - Text("Open Chat 123") - } + val scope = rememberCoroutineScope() + val searchState = rememberSearchBarState() + val textState = rememberTextFieldState() + + val scrollBehavior = SearchBarDefaults.enterAlwaysSearchBarScrollBehavior() + + val inputField = + @Composable { + SearchBarDefaults.InputField( + textFieldState = textState, + searchBarState = searchState, + onSearch = { scope.launch { searchState.animateToCollapsed() } }, + placeholder = { + Text(modifier = Modifier.clearAndSetSemantics() {}, text = "Search") + }, + ) } - } + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + AppBarWithSearch( + state = searchState, + inputField = inputField, + scrollBehavior = scrollBehavior, + ) + }, + content = { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + items(count = 100) { index -> + Box( + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + ) { + Text("Chat $index") + } + } + } + }, + ) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt index 10526dd..7100c1e 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -1,15 +1,63 @@ package su.reya.coop.screens -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text 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.unit.dp +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun ImportScreen() { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Import Screen") +fun ImportScreen( + isLoading: Boolean, + onSave: (secret: String) -> Unit +) { + var secret by remember { mutableStateOf("") } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedTextField( + value = secret, + onValueChange = { secret = it }, + label = { Text("Enter nsec or bunker") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = { + onSave(secret) + }, + modifier = Modifier.fillMaxWidth(), + enabled = secret.isNotBlank() && !isLoading, + ) { + if (isLoading) { + LoadingIndicator() + } else { + Text("Save & Continue") + } + } } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 42d6bb4..fe67e6c 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -56,23 +56,23 @@ class Nostr { } suspend fun connect() { - client?.addRelay( - url = RelayUrl.parse("wss://relay.damus.io"), - capabilities = RelayCapabilities.none() - ) - client?.addRelay( - url = RelayUrl.parse("wss://relay.primal.net"), - capabilities = RelayCapabilities.none() - ) - client?.addRelay( - url = RelayUrl.parse("wss://user.kindpag.es"), - capabilities = RelayCapabilities.none() - ) - client?.addRelay( - url = RelayUrl.parse("https://indexer.coracle.social"), - capabilities = RelayCapabilities.gossip() - ) - client?.connect() + try { + client?.addRelay( + url = RelayUrl.parse("wss://relay.primal.net"), + capabilities = RelayCapabilities.none() + ) + client?.addRelay( + url = RelayUrl.parse("wss://user.kindpag.es"), + capabilities = RelayCapabilities.none() + ) + client?.addRelay( + url = RelayUrl.parse("wss://indexer.coracle.social"), + capabilities = RelayCapabilities.gossip() + ) + client?.connect() + } catch (e: Exception) { + println("Failed to connect to relays: ${e.message}") + } } suspend fun disconnect() { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index e6e5361..1e86250 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -45,7 +45,7 @@ class NostrViewModel( // Connect to bootstrap relays nostr.connect() - + // Get user's signer secret val secret = secretStore.get("user_signer") @@ -119,6 +119,10 @@ class NostrViewModel( } } + fun import(secret: String) { + // TODO: Implement import + } + override fun onCleared() { super.onCleared() // Ensure all relays are disconnect -- 2.49.1 From 109fe28d486813ef5913e553c603f8e3fc8d7cec Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 4 May 2026 12:08:14 +0700 Subject: [PATCH 11/43] add get chat rooms --- .../androidMain/kotlin/su/reya/coop/App.kt | 7 +- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 5 +- shared/build.gradle.kts | 2 +- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 82 ++++++++++- .../kotlin/su/reya/coop/NostrViewModel.kt | 128 +++++++++++++----- .../commonMain/kotlin/su/reya/coop/Room.kt | 76 +++++++++++ 6 files changed, 259 insertions(+), 41 deletions(-) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/Room.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 5cfac06..9ed4aa7 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -30,10 +30,13 @@ import su.reya.coop.screens.OnboardingScreen @Composable fun App(dbPath: String) { val context = LocalContext.current + + // Initialize Nostr and SecretStore val nostr = remember { Nostr() } val secretStore = remember { SecretStore(context) } val viewModel: NostrViewModel = viewModel { NostrViewModel(nostr, secretStore) } + // Dynamic color scheme val darkMode = isSystemInDarkTheme() val colorScheme = when { android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> { @@ -46,6 +49,7 @@ fun App(dbPath: String) { LaunchedEffect(Unit) { viewModel.initAndConnect(dbPath) + viewModel.getChatRooms() } MaterialExpressiveTheme( @@ -60,7 +64,8 @@ fun App(dbPath: String) { if (hasSecret == true) { // Start a background notification handler viewModel.startNotificationHandler() - + // Get chat rooms + viewModel.getChatRooms() // Navigate to the home screen navController.navigate(Screen.Home) { popUpTo(Screen.Onboarding) { inclusive = true } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 7960316..4173f86 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -38,7 +38,10 @@ fun HomeScreen(onOpenChat: (String) -> Unit) { searchBarState = searchState, onSearch = { scope.launch { searchState.animateToCollapsed() } }, placeholder = { - Text(modifier = Modifier.clearAndSetSemantics() {}, text = "Search") + Text( + modifier = Modifier.clearAndSetSemantics() {}, + text = "Find or start a conversation" + ) }, ) } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 9c7d9b2..dc3593a 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -26,7 +26,7 @@ kotlin { commonMain.dependencies { implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") - implementation("su.reya:nostr-sdk-kmp:0.1.1") + implementation("su.reya:nostr-sdk-kmp:0.1.2") } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index fe67e6c..dfa53e4 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -39,6 +39,8 @@ class Nostr { private set var deviceSigner: NostrSigner? = null private set + var contactList: List = emptyList() + private set suspend fun init(dbPath: String) { val lmdb = NostrDatabase.lmdb(dbPath) @@ -211,7 +213,7 @@ class Nostr { } } - suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? { + private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? { try { val filter = Filter().identifier(giftId.toBech32()) val event = client?.database()?.query(filter)?.first() @@ -223,20 +225,22 @@ class Nostr { return null } - suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { + private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { if (rumor.id() == null) return try { + val rngKeys = Keys.generate() val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA); val tags = listOf(Tag.identifier(giftId.toBech32()), Tag.event(rumor.id()!!)) - val event = EventBuilder(kind, rumor.asJson()).tags(tags).signWithKeys(Keys.generate()) + val event = EventBuilder(kind, rumor.asJson()).tags(tags).signWithKeys(rngKeys) client?.database()?.saveEvent(event) + client?.database()?.saveEvent(rumor.signWithKeys(rngKeys)) } catch (e: Exception) { // TODO: log error } } - suspend fun extractRumor(event: Event): UnsignedEvent? { + private suspend fun extractRumor(event: Event): UnsignedEvent? { if (event.kind().asStd() != KindStandard.GIFT_WRAP) return null // Check if the rumor is already cached @@ -266,7 +270,7 @@ class Nostr { return null } - fun conversationId(rumor: UnsignedEvent): Long { + private fun conversationId(rumor: UnsignedEvent): Long { val pubkeys: MutableList = rumor.tags().publicKeys().toMutableList() pubkeys.add(rumor.author()) @@ -278,7 +282,8 @@ class Nostr { return uniqueSortedKeys.hashCode().toLong() } - suspend fun getDefaultRelayList(): Map { + + private suspend fun getDefaultRelayList(): Map { // Construct a list of relays val relayList = mapOf( RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ, @@ -302,7 +307,7 @@ class Nostr { return relayList } - suspend fun getMsgRelayList(): List { + private suspend fun getMsgRelayList(): List { // Construct a list of messaging relays val msgRelayList = listOf( RelayUrl.parse("wss://relay.0xchat.com"), @@ -344,4 +349,67 @@ class Nostr { val contactListEvent = EventBuilder.contactList(defaultContact).sign(signer!!) client?.sendEventNoWait(contactListEvent) } + + suspend fun fetchMetadataBatch(keys: List) { + val filter = + Filter() + .kind(Kind.fromStd(KindStandard.METADATA)) + .authors(keys) + .limit(keys.size.toULong()) + val target = + ReqTarget.manual(mapOf(RelayUrl.parse("wss://user.kindpag.es") to listOf(filter))) + val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) + + client?.subscribe(target = target, id = "metadata-reqs", closeOn = opts) + } + + suspend fun getChatRooms(): Set? { + try { + val userPubkey = signer?.getPublicKey() ?: return null + val kind = Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE) + + // Get all events sent by the user + val sendFilter = Filter().kind(kind).author(userPubkey) + val sendEvents = client?.database()?.query(sendFilter); + + // Get all events sent to the user + val recvFilter = Filter().kind(kind).pubkey(userPubkey) + val recvEvents = client?.database()?.query(recvFilter); + + // Collect all events + val events = sendEvents?.merge(recvEvents!!)?.toVec(); + val rooms: MutableSet = mutableSetOf() + + events + ?.filter { it.tags().publicKeys().isNotEmpty() } + ?.sortedByDescending { it.createdAt().asSecs() } + ?.forEach { event -> + val room = Room.new(rumor = event, userPubkey = userPubkey) + + // Check if the room already exists + if (rooms.contains(room)) return@forEach + + val filter = + Filter().kind(kind).author(userPubkey).pubkeys(room.members.toList()); + + // Check if the user is interacting with the room's members + val isInteracting = client?.database()?.query(filter)?.isEmpty() == false; + + // Check if the room's members are in the contact list + val isContact = contactList.containsAll(room.members) + + // Set the room kind based on interaction status + if (isInteracting || isContact) { + room.kind(RoomKind.Ongoing) + } + + rooms.add(room) + } + + return rooms + } catch (e: Exception) { + println("Failed to get chat rooms: ${e.message}") + } + return null + } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 1e86250..3447a9e 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -3,17 +3,20 @@ package su.reya.coop import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull 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 su.reya.coop.storage.SecretStorage +import kotlin.time.Clock import kotlin.time.Duration class NostrViewModel( @@ -26,11 +29,61 @@ class NostrViewModel( private val _isCreating = MutableStateFlow(false) val isCreating = _isCreating.asStateFlow() - // User metadata store + private val _chatRooms = MutableStateFlow>(emptySet()) + val chatRooms = _chatRooms.asStateFlow() + private val _metadataStore = mutableMapOf>() + private val metadataRequestChannel = Channel(Channel.UNLIMITED) + private val seenPublicKeys = mutableSetOf() + + init { + startMetadataBatchProcessor() + } + + private fun startMetadataBatchProcessor() { + viewModelScope.launch { + 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 >= 20 || (now - lastFlushTime) >= timeout || nextKey == null) { + val keysToRequest = batch.toList() + batch.clear() + nostr.fetchMetadataBatch(keysToRequest) + } + } + } + } + } + + fun requestMetadata(pubkey: PublicKey) { + if (seenPublicKeys.add(pubkey)) { + viewModelScope.launch { + metadataRequestChannel.send(pubkey) + } + } + } fun getMetadata(pubkey: PublicKey): StateFlow { - return _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.asStateFlow() + val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) } + if (flow.value == null) { + requestMetadata(pubkey) + } + return flow.asStateFlow() } fun updateMetadata(pubkey: PublicKey, metadata: Metadata) { @@ -42,37 +95,10 @@ class NostrViewModel( try { // Initialize nostr client nostr.init(dbPath) - // Connect to bootstrap relays nostr.connect() - - // Get user's signer secret - val secret = secretStore.get("user_signer") - - // If no secret is found, show onboarding screen - if (secret == null) { - _hasSecret.value = false - return@launch - } - _hasSecret.value = true - - // Handle different signer types - if (secret.startsWith("nsec1")) { - val keys = Keys.parse(secret) - nostr.setKeySigner(keys) - } else if (secret.startsWith("bunker://")) { - val appKeys = getOrInitAppKeys() - val bunker = NostrConnectUri.parse(secret) - val remote = NostrConnect( - uri = bunker, - appKeys = appKeys, - timeout = Duration.parse("5"), - opts = null - ) - nostr.setRemoteSigner(remote) - } else { - throw IllegalArgumentException("Invalid secret format: $secret") - } + // Get user's secret + getUserSecret() } catch (e: Exception) { println("Failed to connect: ${e.message}") } @@ -87,6 +113,36 @@ class NostrViewModel( } } + suspend fun getUserSecret() { + // Get user's signer secret + val secret = secretStore.get("user_signer") + + // If no secret is found, show onboarding screen + if (secret == null) { + _hasSecret.value = false + return + } + _hasSecret.value = true + + // Handle different signer types + if (secret.startsWith("nsec1")) { + val keys = Keys.parse(secret) + nostr.setKeySigner(keys) + } else if (secret.startsWith("bunker://")) { + val appKeys = getOrInitAppKeys() + val bunker = NostrConnectUri.parse(secret) + val remote = NostrConnect( + uri = bunker, + appKeys = appKeys, + timeout = Duration.parse("5"), + opts = null + ) + nostr.setRemoteSigner(remote) + } else { + throw IllegalArgumentException("Invalid secret format: $secret") + } + } + suspend fun getOrInitAppKeys(): Keys { val secret = secretStore.get("app_keys") @@ -123,6 +179,16 @@ class NostrViewModel( // TODO: Implement import } + fun getChatRooms() { + viewModelScope.launch { + try { + _chatRooms.value = nostr.getChatRooms() ?: emptySet() + } catch (e: Exception) { + println("Failed to get chat rooms: ${e.message}") + } + } + } + override fun onCleared() { super.onCleared() // Ensure all relays are disconnect 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..86c0f1a --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -0,0 +1,76 @@ +package su.reya.coop + +import rust.nostr.sdk.Event +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.TagKind +import rust.nostr.sdk.Timestamp + +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() +) : Comparable { + override fun hashCode(): Int = id.hashCode() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Room) return false + return id == other.id + } + + override fun compareTo(other: Room): Int { + return this.createdAt.asSecs().compareTo(other.createdAt.asSecs()) + } + + companion object { + fun new(rumor: Event, 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 + 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 + ) + } + } + + fun kind(kind: RoomKind): Room { + return this.copy(kind = kind) + } +} + +fun Event.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() +} -- 2.49.1 From 06252ecbb47f32c68d7989369d6b4a171f12b51d Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 6 May 2026 08:39:19 +0700 Subject: [PATCH 12/43] update home screen --- composeApp/build.gradle.kts | 1 + .../composeResources/drawable/ic_avatar.xml | 9 + .../composeResources/drawable/ic_search.xml | 9 + .../androidMain/kotlin/su/reya/coop/App.kt | 116 +++++++------ .../kotlin/su/reya/coop/Navigation.kt | 2 +- .../kotlin/su/reya/coop/screens/ChatScreen.kt | 2 +- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 161 +++++++++++++----- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 20 ++- .../kotlin/su/reya/coop/NostrViewModel.kt | 11 +- 9 files changed, 227 insertions(+), 104 deletions(-) create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_avatar.xml create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_search.xml diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 08b3b24..ec9e031 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -26,6 +26,7 @@ kotlin { 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.1.2") } commonMain.dependencies { implementation(libs.compose.runtime) 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_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/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 9ed4aa7..672f1ed 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -6,13 +6,15 @@ import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.expressiveLightColorScheme 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.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost @@ -26,6 +28,10 @@ import su.reya.coop.screens.ImportScreen import su.reya.coop.screens.NewIdentityScreen import su.reya.coop.screens.OnboardingScreen +val LocalNostrViewModel = staticCompositionLocalOf { + error("No NostrViewModel provided") +} + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun App(dbPath: String) { @@ -44,7 +50,7 @@ fun App(dbPath: String) { } darkMode -> darkColorScheme() - else -> lightColorScheme() + else -> expressiveLightColorScheme() } LaunchedEffect(Unit) { @@ -55,65 +61,67 @@ fun App(dbPath: String) { MaterialExpressiveTheme( colorScheme = colorScheme, ) { - rememberCoroutineScope() - val navController = rememberNavController() - val hasSecret by viewModel.hasSecret.collectAsState(initial = null) + CompositionLocalProvider(LocalNostrViewModel provides viewModel) { + rememberCoroutineScope() + val navController = rememberNavController() + val hasSecret by viewModel.hasSecret.collectAsState(initial = null) - LaunchedEffect(hasSecret) { - // Navigate to the home screen if the secret is already set - if (hasSecret == true) { - // Start a background notification handler - viewModel.startNotificationHandler() - // Get chat rooms - viewModel.getChatRooms() - // Navigate to the home screen - navController.navigate(Screen.Home) { - popUpTo(Screen.Onboarding) { inclusive = true } + LaunchedEffect(hasSecret) { + // Navigate to the home screen if the secret is already set + if (hasSecret == true) { + // Start a background notification handler + viewModel.startNotificationHandler() + // Get chat rooms + viewModel.getChatRooms() + // Navigate to the home screen + navController.navigate(Screen.Home) { + popUpTo(Screen.Onboarding) { inclusive = true } + } } } - } - // Show loading screen while initializing - if (hasSecret == null) return@MaterialExpressiveTheme + // Show loading screen while initializing + if (hasSecret == null) return@CompositionLocalProvider - NavHost( - navController = navController, - startDestination = if (hasSecret == true) 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() + NavHost( + navController = navController, + startDestination = if (hasSecret == true) 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, - onSave = { secret -> - viewModel.import(secret) - } - ) - } - composable { backStackEntry -> - val isCreating by viewModel.isCreating.collectAsState() + ImportScreen( + isLoading = isCreating, + onSave = { secret -> + viewModel.importIdentity(secret) + } + ) + } + composable { backStackEntry -> + val isCreating by viewModel.isCreating.collectAsState() - NewIdentityScreen( - isLoading = isCreating, - onSave = { name, bio, uri -> - viewModel.createIdentity(name, bio, uri?.toString()) - } - ) - } - composable { backStackEntry -> - HomeScreen( - onOpenChat = { id -> navController.navigate(Screen.Chat(id)) } - ) - } - composable { backStackEntry -> - val chat: Screen.Chat = backStackEntry.toRoute() - ChatScreen(id = chat.id) + NewIdentityScreen( + isLoading = isCreating, + onSave = { name, bio, uri -> + viewModel.createIdentity(name, bio, uri?.toString()) + } + ) + } + composable { backStackEntry -> + HomeScreen( + onOpenChat = { id -> navController.navigate(Screen.Chat(id)) } + ) + } + composable { backStackEntry -> + val chat: Screen.Chat = backStackEntry.toRoute() + ChatScreen(id = chat.id) + } } } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt index e08a11c..521babf 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt @@ -7,7 +7,7 @@ sealed interface Screen { data object Home : Screen @Serializable - data class Chat(val id: String) : Screen + data class Chat(val id: Long) : Screen @Serializable data object Onboarding : Screen diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index 5677e4b..bc9f2f3 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @Composable -fun ChatScreen(id: String) { +fun ChatScreen(id: Long) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Chat Screen (ID: $id)") } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 4173f86..195a12e 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -1,76 +1,151 @@ 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.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.lazy.LazyColumn -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.material3.AppBarWithSearch +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.rememberSearchBarState +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch +import coil3.compose.AsyncImage +import coop.composeapp.generated.resources.Res +import coop.composeapp.generated.resources.ic_avatar +import coop.composeapp.generated.resources.ic_search +import org.jetbrains.compose.resources.painterResource +import su.reya.coop.LocalNostrViewModel +import su.reya.coop.Room @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable -fun HomeScreen(onOpenChat: (String) -> Unit) { - val scope = rememberCoroutineScope() - val searchState = rememberSearchBarState() - val textState = rememberTextFieldState() - - val scrollBehavior = SearchBarDefaults.enterAlwaysSearchBarScrollBehavior() - - val inputField = - @Composable { - SearchBarDefaults.InputField( - textFieldState = textState, - searchBarState = searchState, - onSearch = { scope.launch { searchState.animateToCollapsed() } }, - placeholder = { - Text( - modifier = Modifier.clearAndSetSemantics() {}, - text = "Find or start a conversation" - ) - }, - ) - } +fun HomeScreen(onOpenChat: (Long) -> Unit) { + val viewModel = LocalNostrViewModel.current + val userProfile by viewModel.getUserProfile().collectAsState(initial = null) + val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList()) Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.surfaceContainer, topBar = { - AppBarWithSearch( - state = searchState, - inputField = inputField, - scrollBehavior = scrollBehavior, + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + title = { + Text( + text = "Coop", + style = MaterialTheme.typography.titleMediumEmphasized + ) + }, + actions = { + // Search + IconButton(onClick = { /* TODO: Open search */ }) { + Icon( + painter = painterResource(Res.drawable.ic_search), + contentDescription = "Search" + ) + } + // User + IconButton(onClick = { /* TODO: Open profile */ }) { + if (userProfile?.asRecord()?.picture != null) { + AsyncImage( + model = userProfile?.asRecord()?.picture, + contentDescription = "User Avatar", + modifier = Modifier + .size(32.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + } else { + Icon( + painter = painterResource(Res.drawable.ic_avatar), + contentDescription = "User" + ) + } + } + } ) }, content = { innerPadding -> - LazyColumn( + Surface( modifier = Modifier .fillMaxSize() - .padding(innerPadding), + .padding(top = innerPadding.calculateTopPadding()), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), ) { - items(count = 100) { index -> + if (chatRooms.isEmpty()) { Box( - modifier = Modifier - .fillMaxWidth() - .height(50.dp) + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { - Text("Chat $index") + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "No chats yet", + style = MaterialTheme.typography.titleLargeEmphasized, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Your conversations will appear here.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline + ) + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items(chatRooms.toList(), key = { it.id }) { room -> + ChatRoom( + room = room, + onClick = { onOpenChat(room.id) } + ) + } } } } }, ) } + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ChatRoom(room: Room, onClick: () -> Unit) { + val title = room.subject ?: "Room" + + ListItem( + modifier = Modifier.clickable { onClick }, + headlineContent = { + Text( + text = title, + style = MaterialTheme.typography.titleMediumEmphasized + ) + }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) +} + diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index dfa53e4..75169ba 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -39,6 +39,8 @@ class Nostr { private set var deviceSigner: NostrSigner? = null private set + var userPubkey: PublicKey? = null + private set var contactList: List = emptyList() private set @@ -82,13 +84,23 @@ class Nostr { } suspend fun setKeySigner(keys: Keys) { - signer = NostrSigner.keys(keys) - getUserMetadata() + try { + signer = NostrSigner.keys(keys) + userPubkey = signer?.getPublicKey() + getUserMetadata() + } catch (e: Exception) { + println("Failed to set signer: ${e.message}") + } } suspend fun setRemoteSigner(remote: NostrConnect) { - signer = NostrSigner.nostrConnect(remote) - getUserMetadata() + try { + signer = NostrSigner.nostrConnect(remote) + userPubkey = signer?.getPublicKey() + getUserMetadata() + } catch (e: Exception) { + println("Failed to set remote signer: ${e.message}") + } } suspend fun isSignedByUser(event: Event): Boolean { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 3447a9e..c75ac0c 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -90,6 +90,14 @@ class NostrViewModel( _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata } + fun getUserProfile(): StateFlow { + return try { + getMetadata(nostr.userPubkey!!) + } catch (e: Exception) { + MutableStateFlow(null) + } + } + fun initAndConnect(dbPath: String) { viewModelScope.launch { try { @@ -175,10 +183,11 @@ class NostrViewModel( } } - fun import(secret: String) { + fun importIdentity(secret: String) { // TODO: Implement import } + fun getChatRooms() { viewModelScope.launch { try { -- 2.49.1 From eb2f543f53c22b9a196550d9ff494fb87431be8e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 6 May 2026 09:27:34 +0700 Subject: [PATCH 13/43] add bottom sheet --- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 57 ++++++++++++++++++- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 7 +++ .../kotlin/su/reya/coop/NostrViewModel.kt | 8 +++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 195a12e..aa9bcf2 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -10,21 +10,28 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState 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 @@ -45,6 +52,9 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) { val userProfile by viewModel.getUserProfile().collectAsState(initial = null) val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList()) + val sheetState = rememberModalBottomSheetState() + var showBottomSheet by remember { mutableStateOf(false) } + Scaffold( containerColor = MaterialTheme.colorScheme.surfaceContainer, topBar = { @@ -67,7 +77,7 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) { ) } // User - IconButton(onClick = { /* TODO: Open profile */ }) { + IconButton(onClick = { showBottomSheet = true }) { if (userProfile?.asRecord()?.picture != null) { AsyncImage( model = userProfile?.asRecord()?.picture, @@ -125,6 +135,51 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) { } } } + + if (showBottomSheet) { + ModalBottomSheet( + onDismissRequest = { showBottomSheet = false }, + sheetState = sheetState, + ) { + val userName = + userProfile?.asRecord()?.displayName + ?: userProfile?.asRecord()?.name + ?: "No name" + + Column(modifier = Modifier.padding(16.dp)) { + ListItem( + headlineContent = { + Text( + text = userName, + style = MaterialTheme.typography.titleMediumEmphasized + ) + }, + leadingContent = { + if (userProfile?.asRecord()?.picture != null) { + AsyncImage( + model = userProfile?.asRecord()?.picture, + contentDescription = "User Avatar", + modifier = Modifier + .size(32.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + } else { + Icon( + painter = painterResource(Res.drawable.ic_avatar), + contentDescription = "User" + ) + } + } + ) + HorizontalDivider() + Button( + onClick = { viewModel.logout() }, + content = { Text("Logout") } + ) + } + } + } } }, ) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 75169ba..2b64f3a 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -83,6 +83,13 @@ class Nostr { client?.shutdown() } + fun exit() { + signer = null + deviceSigner = null + userPubkey = null + contactList = emptyList() + } + suspend fun setKeySigner(keys: Keys) { try { signer = NostrSigner.keys(keys) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index c75ac0c..b4f3532 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -187,6 +187,14 @@ class NostrViewModel( // TODO: Implement import } + fun logout() { + viewModelScope.launch { + _hasSecret.value = false + _chatRooms.value = emptySet() + secretStore.clear("user_signer") + nostr.exit() + } + } fun getChatRooms() { viewModelScope.launch { -- 2.49.1 From 8b5a8b0e48360cb0cccd0ccec74e5b727f0c6dca Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 6 May 2026 14:25:04 +0700 Subject: [PATCH 14/43] improve error handling --- .../androidMain/kotlin/su/reya/coop/App.kt | 29 +++- .../su/reya/coop/screens/NewIdentityScreen.kt | 137 ++++++++++-------- .../kotlin/su/reya/coop/NostrViewModel.kt | 91 ++++++++---- 3 files changed, 156 insertions(+), 101 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 672f1ed..e0af954 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -3,6 +3,7 @@ package su.reya.coop import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme @@ -32,6 +33,10 @@ val LocalNostrViewModel = staticCompositionLocalOf { error("No NostrViewModel provided") } +val LocalSnackbarHostState = staticCompositionLocalOf { + error("No SnackbarHostState provided") +} + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun App(dbPath: String) { @@ -53,24 +58,32 @@ fun App(dbPath: String) { else -> expressiveLightColorScheme() } + // Snackbar + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(Unit) { viewModel.initAndConnect(dbPath) + viewModel.startNotificationHandler() viewModel.getChatRooms() + viewModel.errorEvents.collect { message -> + snackbarHostState.showSnackbar(message) + } } MaterialExpressiveTheme( colorScheme = colorScheme, ) { - CompositionLocalProvider(LocalNostrViewModel provides viewModel) { + CompositionLocalProvider( + LocalNostrViewModel provides viewModel, + LocalSnackbarHostState provides snackbarHostState, + ) { rememberCoroutineScope() val navController = rememberNavController() - val hasSecret by viewModel.hasSecret.collectAsState(initial = null) + val emptySecret by viewModel.emptySecret.collectAsState(initial = null) - LaunchedEffect(hasSecret) { + LaunchedEffect(emptySecret) { // Navigate to the home screen if the secret is already set - if (hasSecret == true) { - // Start a background notification handler - viewModel.startNotificationHandler() + if (emptySecret == false) { // Get chat rooms viewModel.getChatRooms() // Navigate to the home screen @@ -81,11 +94,11 @@ fun App(dbPath: String) { } // Show loading screen while initializing - if (hasSecret == null) return@CompositionLocalProvider + if (emptySecret == null) return@CompositionLocalProvider NavHost( navController = navController, - startDestination = if (hasSecret == true) Screen.Home else Screen.Onboarding + startDestination = if (emptySecret == false) Screen.Home else Screen.Onboarding ) { composable { backStackEntry -> OnboardingScreen( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt index 4486217..a7ee9a4 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -20,6 +20,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -33,6 +35,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import su.reya.coop.LocalSnackbarHostState @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -40,6 +43,7 @@ fun NewIdentityScreen( isLoading: Boolean, 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) } @@ -49,70 +53,79 @@ fun NewIdentityScreen( picture = uri } - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = "New Identity", - style = MaterialTheme.typography.headlineMediumEmphasized - ) - Box( - modifier = Modifier - .size(120.dp) - .clip(CircleShape), - 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() - + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + snackbarHost = { SnackbarHost(snackbarHostState) }, + content = { innerPadding -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(top = innerPadding.calculateTopPadding()), + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - // + Box( + modifier = Modifier + .size(120.dp) + .clip(CircleShape), + 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() + + ) { + // + } + } + } + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = bio, + onValueChange = { bio = it }, + label = { Text("Bio:") }, + modifier = Modifier + .fillMaxWidth() + .height(150.dp), + minLines = 3, + ) + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = { + onSave(name, bio, picture) + }, + modifier = Modifier.fillMaxWidth(), + enabled = name.isNotBlank() && !isLoading, + ) { + if (isLoading) { + LoadingIndicator() + } else { + Text("Save & Continue") + } + } } } } - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Name") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - OutlinedTextField( - value = bio, - onValueChange = { bio = it }, - label = { Text("Bio:") }, - modifier = Modifier - .fillMaxWidth() - .height(150.dp), - minLines = 3, - ) - Spacer(modifier = Modifier.weight(1f)) - Button( - onClick = { - onSave(name, bio, picture) - }, - modifier = Modifier.fillMaxWidth(), - enabled = name.isNotBlank() && !isLoading, - ) { - if (isLoading) { - LoadingIndicator() - } else { - Text("Save & Continue") - } - } - } + ) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index b4f3532..c6cca12 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -23,8 +24,8 @@ class NostrViewModel( private val nostr: Nostr, private val secretStore: SecretStorage ) : ViewModel() { - private val _hasSecret = MutableStateFlow(null) - val hasSecret = _hasSecret.asStateFlow() + private val _emptySecret = MutableStateFlow(null) + val emptySecret = _emptySecret.asStateFlow() private val _isCreating = MutableStateFlow(false) val isCreating = _isCreating.asStateFlow() @@ -32,6 +33,9 @@ class NostrViewModel( private val _chatRooms = MutableStateFlow>(emptySet()) val chatRooms = _chatRooms.asStateFlow() + private val _errorEvents = Channel(Channel.BUFFERED) + val errorEvents = _errorEvents.receiveAsFlow() + private val _metadataStore = mutableMapOf>() private val metadataRequestChannel = Channel(Channel.UNLIMITED) private val seenPublicKeys = mutableSetOf() @@ -40,6 +44,12 @@ class NostrViewModel( startMetadataBatchProcessor() } + private fun showError(message: String) { + viewModelScope.launch { + _errorEvents.send(message) + } + } + private fun startMetadataBatchProcessor() { viewModelScope.launch { val batch = mutableSetOf() @@ -91,11 +101,7 @@ class NostrViewModel( } fun getUserProfile(): StateFlow { - return try { - getMetadata(nostr.userPubkey!!) - } catch (e: Exception) { - MutableStateFlow(null) - } + return getMetadata(nostr.userPubkey!!) } fun initAndConnect(dbPath: String) { @@ -108,7 +114,7 @@ class NostrViewModel( // Get user's secret getUserSecret() } catch (e: Exception) { - println("Failed to connect: ${e.message}") + showError("Failed to initialize Nostr: ${e.message}") } } } @@ -121,31 +127,43 @@ class NostrViewModel( } } + fun logout() { + viewModelScope.launch { + _emptySecret.value = true + _chatRooms.value = emptySet() + secretStore.clear("user_signer") + nostr.exit() + } + } + suspend fun getUserSecret() { // Get user's signer secret val secret = secretStore.get("user_signer") // If no secret is found, show onboarding screen - if (secret == null) { - _hasSecret.value = false - return + when (secret) { + null -> { + _emptySecret.value = true + return + } + + else -> _emptySecret.value = false } - _hasSecret.value = true // Handle different signer types if (secret.startsWith("nsec1")) { val keys = Keys.parse(secret) nostr.setKeySigner(keys) } else if (secret.startsWith("bunker://")) { - val appKeys = getOrInitAppKeys() - val bunker = NostrConnectUri.parse(secret) - val remote = NostrConnect( - uri = bunker, - appKeys = appKeys, - timeout = Duration.parse("5"), - opts = null - ) - nostr.setRemoteSigner(remote) + try { + val appKeys = getOrInitAppKeys() + val bunker = NostrConnectUri.parse(secret) + val timeout = Duration.parse("50") // 50 seconds timeout + val remote = NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null) + nostr.setRemoteSigner(remote) + } catch (e: Exception) { + showError("Error: ${e.message}") + } } else { throw IllegalArgumentException("Invalid secret format: $secret") } @@ -178,21 +196,32 @@ class NostrViewModel( // Save secret to the secret storage secretStore.set("user_signer", secret) } catch (e: Exception) { - println("Create identity failed: $e") + showError("Error: ${e.message}") } } } fun importIdentity(secret: String) { - // TODO: Implement import - } - - fun logout() { viewModelScope.launch { - _hasSecret.value = false - _chatRooms.value = emptySet() - secretStore.clear("user_signer") - nostr.exit() + if (secret.startsWith("nsec1")) { + val keys = Keys.parse(secret) + nostr.setKeySigner(keys) + secretStore.set("user_signer", secret) + } else if (secret.startsWith("bunker://")) { + try { + val appKeys = getOrInitAppKeys() + val bunker = NostrConnectUri.parse(secret) + val timeout = Duration.parse("50") // 50 seconds timeout + val remote = + NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null) + nostr.setRemoteSigner(remote) + secretStore.set("user_signer", secret) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } else { + showError("Please enter a valid Secret or Bunker URI.") + } } } @@ -201,7 +230,7 @@ class NostrViewModel( try { _chatRooms.value = nostr.getChatRooms() ?: emptySet() } catch (e: Exception) { - println("Failed to get chat rooms: ${e.message}") + showError("Error: ${e.message}") } } } -- 2.49.1 From 7acff87d9bbf211d8d622604e5afd5fa1378a052 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 7 May 2026 15:27:47 +0700 Subject: [PATCH 15/43] update onboarding screen --- .../composeResources/drawable/coop.xml | 32 ++++ .../composeResources/drawable/ic_scanner.xml | 9 + .../su/reya/coop/screens/OnboardingScreen.kt | 169 +++++++++++++++++- 3 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 composeApp/src/androidMain/composeResources/drawable/coop.xml create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_scanner.xml 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_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/kotlin/su/reya/coop/screens/OnboardingScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt index 2f9d7f0..bf25683 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -1,29 +1,180 @@ 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.Row 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.layout.width import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +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 coop.composeapp.generated.resources.ic_scanner +import org.jetbrains.compose.resources.painterResource +import su.reya.coop.LocalSnackbarHostState +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("Onboarding Screen") - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = onOpenImport) { - Text("Import") + 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)) + Row( + modifier = Modifier.fillMaxWidth(), + ) { + FilledTonalButton( + onClick = onOpenImport, + modifier = Modifier + .weight(2f) + .height(ButtonDefaults.MediumContainerHeight), + ) { + Text( + text = "Import identity", + style = MaterialTheme.typography.titleMediumEmphasized, + ) + } + Spacer(modifier = Modifier.width(8.dp)) + FilledTonalIconButton( + onClick = onOpenImport, + modifier = Modifier + .weight(1f) + .height(ButtonDefaults.MediumContainerHeight), + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ) + ) { + Icon( + painter = painterResource(Res.drawable.ic_scanner), + contentDescription = "Scan QR" + ) + } + } + } + } + } } - Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = onOpenNew) { - Text("New") + } + ) +} + +@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 + ) + ) + } + } + } } } } -- 2.49.1 From 5c31f7a0d633b7b69e8642e3ced2e5254d60dda9 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 8 May 2026 12:17:30 +0700 Subject: [PATCH 16/43] fix create identity flow --- .../drawable/ic_arrow_back.xml | 10 ++++ .../androidMain/kotlin/su/reya/coop/App.kt | 1 + .../su/reya/coop/screens/NewIdentityScreen.kt | 54 ++++++++++++++++--- .../su/reya/coop/screens/OnboardingScreen.kt | 48 +++++------------ .../commonMain/kotlin/su/reya/coop/Nostr.kt | 18 ++++--- .../kotlin/su/reya/coop/NostrViewModel.kt | 14 ++++- 6 files changed, 95 insertions(+), 50 deletions(-) create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_arrow_back.xml 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/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index e0af954..ef39dc8 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -121,6 +121,7 @@ fun App(dbPath: String) { NewIdentityScreen( isLoading = isCreating, + onBack = { navController.popBackStack() }, onSave = { name, bio, uri -> viewModel.createIdentity(name, bio, uri?.toString()) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt index a7ee9a4..2566071 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -3,6 +3,7 @@ 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 @@ -14,9 +15,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape 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.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -24,6 +29,8 @@ 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.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -35,12 +42,17 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale 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_avatar +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 @@ -56,17 +68,34 @@ fun NewIdentityScreen( Scaffold( containerColor = MaterialTheme.colorScheme.surfaceContainer, snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text("Create a new identity") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(Res.drawable.ic_arrow_back), + contentDescription = "User" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ) + ) + }, content = { innerPadding -> Surface( modifier = Modifier .fillMaxSize() .padding(top = innerPadding.calculateTopPadding()), - color = MaterialTheme.colorScheme.surfaceContainer, + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), ) { Column( modifier = Modifier .fillMaxSize() - .padding(16.dp) + .padding(24.dp) .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) @@ -74,7 +103,8 @@ fun NewIdentityScreen( Box( modifier = Modifier .size(120.dp) - .clip(CircleShape), + .clip(CircleShape) + .clickable { launcher.launch("image/*") }, contentAlignment = Alignment.Center ) { if (picture != null) { @@ -90,7 +120,14 @@ fun NewIdentityScreen( modifier = Modifier.fillMaxSize() ) { - // + Box(contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(Res.drawable.ic_avatar), + contentDescription = "Pick avatar", + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } } @@ -115,13 +152,18 @@ fun NewIdentityScreen( onClick = { onSave(name, bio, picture) }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .height(ButtonDefaults.LargeContainerHeight), enabled = name.isNotBlank() && !isLoading, ) { if (isLoading) { LoadingIndicator() } else { - Text("Save & Continue") + Text( + text = "Save & Continue", + style = MaterialTheme.typography.titleLargeEmphasized, + ) } } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt index bf25683..9f98964 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -3,21 +3,16 @@ 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.Row 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.layout.width import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -34,7 +29,6 @@ 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 coop.composeapp.generated.resources.ic_scanner import org.jetbrains.compose.resources.painterResource import su.reya.coop.LocalSnackbarHostState @@ -92,36 +86,20 @@ fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) { ) } Spacer(modifier = Modifier.size(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), + FilledTonalButton( + onClick = onOpenImport, + modifier = Modifier + .fillMaxWidth() + .height(ButtonDefaults.LargeContainerHeight), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ), ) { - FilledTonalButton( - onClick = onOpenImport, - modifier = Modifier - .weight(2f) - .height(ButtonDefaults.MediumContainerHeight), - ) { - Text( - text = "Import identity", - style = MaterialTheme.typography.titleMediumEmphasized, - ) - } - Spacer(modifier = Modifier.width(8.dp)) - FilledTonalIconButton( - onClick = onOpenImport, - modifier = Modifier - .weight(1f) - .height(ButtonDefaults.MediumContainerHeight), - colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer - ) - ) { - Icon( - painter = painterResource(Res.drawable.ic_scanner), - contentDescription = "Scan QR" - ) - } + Text( + text = "Import identity", + style = MaterialTheme.typography.titleLargeEmphasized, + ) } } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 2b64f3a..bda2061 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -25,12 +25,14 @@ import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.ReqExitPolicy import rust.nostr.sdk.ReqTarget +import rust.nostr.sdk.SleepWhenIdle import rust.nostr.sdk.SubscribeAutoCloseOptions import rust.nostr.sdk.Tag import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnwrappedGift import rust.nostr.sdk.extractMessagingRelayList +import kotlin.time.Duration class Nostr { var client: Client? = null @@ -47,6 +49,7 @@ class Nostr { suspend fun init(dbPath: String) { val lmdb = NostrDatabase.lmdb(dbPath) val gossip = NostrGossip.inMemory() + val idleTimeout = Duration.parse("5m") client = ClientBuilder() @@ -56,6 +59,7 @@ class Nostr { .maxRelays(20u) .verifySubscriptions(false) .automaticAuthentication(false) + .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) .build() } @@ -343,30 +347,30 @@ class Nostr { } suspend fun createIdentity(keys: Keys, name: String, bio: String, picture: String?) { - // Set signer - signer = NostrSigner.keys(keys) - // Send relay list event val relayList = getDefaultRelayList() - val relayListEvent = EventBuilder.relayList(relayList).sign(signer!!); + val relayListEvent = EventBuilder.relayList(relayList).signWithKeys(keys); client?.sendEvent(relayListEvent) // Send messaging relay list event val msgRelayList = getMsgRelayList() - val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).sign(signer!!) + val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).signWithKeys(keys) client?.sendEventNoWait(msgRelayListEvent) // Send metadata event val metadata = Metadata.fromRecord(MetadataRecord(name = name, about = bio, picture = picture)) - val metadataEvent = EventBuilder.metadata(metadata).sign(signer!!) + val metadataEvent = EventBuilder.metadata(metadata).signWithKeys(keys) client?.sendEventNoWait(metadataEvent) // Send contact list event val defaultContact = listOf(Contact(publicKey = PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x"))) - val contactListEvent = EventBuilder.contactList(defaultContact).sign(signer!!) + val contactListEvent = EventBuilder.contactList(defaultContact).signWithKeys(keys) client?.sendEventNoWait(contactListEvent) + + // Set signer + setKeySigner(keys) } suspend fun fetchMetadataBatch(keys: List) { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index c6cca12..82e55f5 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -158,7 +158,7 @@ class NostrViewModel( try { val appKeys = getOrInitAppKeys() val bunker = NostrConnectUri.parse(secret) - val timeout = Duration.parse("50") // 50 seconds timeout + val timeout = Duration.parse("50s") // 50 seconds timeout val remote = NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null) nostr.setRemoteSigner(remote) } catch (e: Exception) { @@ -189,12 +189,18 @@ class NostrViewModel( try { val keys = Keys.generate() val secret = keys.secretKey().toBech32() + // Set loading state _isCreating.value = true + // Create identity nostr.createIdentity(keys, name, bio, picture) + // 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}") } @@ -207,15 +213,19 @@ class NostrViewModel( val keys = Keys.parse(secret) nostr.setKeySigner(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("50") // 50 seconds timeout + val timeout = Duration.parse("50s") // 50 seconds timeout val remote = NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null) nostr.setRemoteSigner(remote) secretStore.set("user_signer", secret) + // Set an empty secret state + _emptySecret.value = false } catch (e: Exception) { showError("Error: ${e.message}") } -- 2.49.1 From e824aa7e16a83080b20754bc40dcb562ab29bd19 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 9 May 2026 09:07:27 +0700 Subject: [PATCH 17/43] fix --- .../androidMain/kotlin/su/reya/coop/App.kt | 1 + .../su/reya/coop/screens/ImportScreen.kt | 108 +++++++++++++----- .../su/reya/coop/screens/NewIdentityScreen.kt | 2 +- gradle/libs.versions.toml | 5 + shared/build.gradle.kts | 10 +- .../kotlin/su/reya/coop/CoopWebSocket.kt | 75 ++++++++++++ .../commonMain/kotlin/su/reya/coop/Nostr.kt | 80 +++++++------ .../kotlin/su/reya/coop/NostrViewModel.kt | 22 ++-- 8 files changed, 222 insertions(+), 81 deletions(-) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/CoopWebSocket.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index ef39dc8..9be31f4 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -111,6 +111,7 @@ fun App(dbPath: String) { ImportScreen( isLoading = isCreating, + onBack = { navController.popBackStack() }, onSave = { secret -> viewModel.importIdentity(secret) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt index 7100c1e..9078a4e 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -5,14 +5,25 @@ 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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape 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.MaterialTheme import androidx.compose.material3.OutlinedTextField +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.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -21,43 +32,84 @@ import androidx.compose.runtime.setValue 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 su.reya.coop.LocalSnackbarHostState @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ImportScreen( isLoading: Boolean, + onBack: () -> Unit, onSave: (secret: String) -> Unit ) { + val snackbarHostState = LocalSnackbarHostState.current var secret by remember { mutableStateOf("") } - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - OutlinedTextField( - value = secret, - onValueChange = { secret = it }, - label = { Text("Enter nsec or bunker") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - Spacer(modifier = Modifier.weight(1f)) - Button( - onClick = { - onSave(secret) - }, - modifier = Modifier.fillMaxWidth(), - enabled = secret.isNotBlank() && !isLoading, - ) { - if (isLoading) { - LoadingIndicator() - } else { - Text("Save & Continue") + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text("Import") }, + 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(24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedTextField( + value = secret, + onValueChange = { secret = it }, + label = { Text("Enter nsec or bunker") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = { + onSave(secret) + }, + modifier = Modifier + .fillMaxWidth() + .height(ButtonDefaults.LargeContainerHeight), + enabled = secret.isNotBlank() && !isLoading, + ) { + if (isLoading) { + LoadingIndicator() + } else { + Text( + text = "Save & Continue", + style = MaterialTheme.typography.titleLargeEmphasized, + ) + } + } + } } } - } + ) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt index 2566071..ca02941 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -75,7 +75,7 @@ fun NewIdentityScreen( IconButton(onClick = onBack) { Icon( painter = painterResource(Res.drawable.ic_arrow_back), - contentDescription = "User" + contentDescription = "Back" ) } }, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5cbba71..dc494c5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ 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" } @@ -36,6 +37,10 @@ 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" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index dc3593a..796f8a2 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -26,7 +26,15 @@ kotlin { commonMain.dependencies { implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") - implementation("su.reya:nostr-sdk-kmp:0.1.2") + implementation("su.reya:nostr-sdk-kmp:0.1.5") + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.websockets) + } + 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/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/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index bda2061..7a26456 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -1,5 +1,7 @@ package su.reya.coop +import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.WebSockets import rust.nostr.sdk.Client import rust.nostr.sdk.ClientBuilder import rust.nostr.sdk.ClientNotification @@ -12,6 +14,7 @@ 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.NostrConnect @@ -32,6 +35,7 @@ import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnwrappedGift import rust.nostr.sdk.extractMessagingRelayList +import rust.nostr.sdk.initLogger import kotlin.time.Duration class Nostr { @@ -47,39 +51,37 @@ class Nostr { private set suspend fun init(dbPath: String) { - val lmdb = NostrDatabase.lmdb(dbPath) - val gossip = NostrGossip.inMemory() - val idleTimeout = Duration.parse("5m") - - client = - ClientBuilder() - .database(lmdb) - .gossip(gossip) - .gossipConfig(GossipConfig().noBackgroundRefresh()) - .maxRelays(20u) - .verifySubscriptions(false) - .automaticAuthentication(false) - .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) - .build() - } - - suspend fun connect() { try { - client?.addRelay( - url = RelayUrl.parse("wss://relay.primal.net"), - capabilities = RelayCapabilities.none() - ) - client?.addRelay( - url = RelayUrl.parse("wss://user.kindpag.es"), - capabilities = RelayCapabilities.none() - ) + // 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() + .websocketTransport(CoopWebSocketClient(httpClient)) + .database(lmdb) + .gossip(gossip) + .gossipConfig(GossipConfig().noBackgroundRefresh()) + .verifySubscriptions(false) + .automaticAuthentication(false) + .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) + .build() + + client?.addRelay(RelayUrl.parse("wss://relay.primal.net")) + client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) client?.addRelay( url = RelayUrl.parse("wss://indexer.coracle.social"), capabilities = RelayCapabilities.gossip() ) - client?.connect() + client?.connect(Duration.parse("10s")) } catch (e: Exception) { - println("Failed to connect to relays: ${e.message}") + println("Failed to initialize client: ${e.message}") } } @@ -98,6 +100,8 @@ class Nostr { try { signer = NostrSigner.keys(keys) userPubkey = signer?.getPublicKey() + + // Fetch metadata for current user getUserMetadata() } catch (e: Exception) { println("Failed to set signer: ${e.message}") @@ -108,6 +112,8 @@ class Nostr { try { signer = NostrSigner.nostrConnect(remote) userPubkey = signer?.getPublicKey() + + // Fetch metadata for current user getUserMetadata() } catch (e: Exception) { println("Failed to set remote signer: ${e.message}") @@ -123,19 +129,17 @@ class Nostr { } suspend fun getUserMetadata() { - val userPubkey = signer?.getPublicKey() ?: return - // Get the latest metadata event val metadataFilter = - Filter().author(userPubkey).limit(1u).kind(Kind.fromStd(KindStandard.METADATA)) + Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.METADATA)) // Get the latest contact list event val contactFilter = - Filter().author(userPubkey).limit(1u).kind(Kind.fromStd(KindStandard.CONTACT_LIST)) + Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.CONTACT_LIST)) // Get the latest messaging relay list event val msgRelayFilter = - Filter().author(userPubkey).limit(1u).kind(Kind.fromStd(KindStandard.INBOX_RELAYS)) + Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.INBOX_RELAYS)) // Construct a target that includes all filters val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter)) @@ -170,11 +174,11 @@ class Nostr { suspend fun handleNotifications(onMetadataUpdate: (PublicKey, Metadata) -> Unit) { val now = Timestamp.now() - val notifications = client?.notifications() val processedEvent = mutableSetOf() - + val notifications = client?.notifications() ?: return + while (true) { - val notification = notifications?.next() ?: break + val notification = notifications.next() ?: continue when (notification) { is ClientNotification.Message -> { @@ -189,7 +193,7 @@ class Nostr { if (processedEvent.contains(event.id())) continue processedEvent.add(event.id()) - if (event.kind().asStd() == KindStandard.METADATA) { + if (event.kind().asStd()?.equals(KindStandard.METADATA) == true) { try { val metadata = Metadata.fromJson(event.content()) onMetadataUpdate(event.author(), metadata) @@ -198,13 +202,13 @@ class Nostr { } } - if (event.kind().asStd() == KindStandard.INBOX_RELAYS) { + if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) { if (isSignedByUser(event = event)) { getUserMessages(msgRelayList = event) } } - if (event.kind().asStd() == KindStandard.GIFT_WRAP) { + if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) { try { val rumor = extractRumor(event) // TODO: Handle rumor diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 82e55f5..ca6d014 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -101,21 +101,17 @@ class NostrViewModel( } fun getUserProfile(): StateFlow { - return getMetadata(nostr.userPubkey!!) + return nostr.userPubkey?.let { getMetadata(it) } ?: MutableStateFlow(null).asStateFlow() } - fun initAndConnect(dbPath: String) { - viewModelScope.launch { - try { - // Initialize nostr client - nostr.init(dbPath) - // Connect to bootstrap relays - nostr.connect() - // Get user's secret - getUserSecret() - } catch (e: Exception) { - showError("Failed to initialize Nostr: ${e.message}") - } + suspend fun initAndConnect(dbPath: String) { + try { + // Initialize nostr client + nostr.init(dbPath) + // Get user's secret + getUserSecret() + } catch (e: Exception) { + showError("Failed to initialize Nostr: ${e.message}") } } -- 2.49.1 From 52ae2e521f17ea2915453b673510b85b86bb99d9 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 9 May 2026 16:34:56 +0700 Subject: [PATCH 18/43] add blossom --- .../androidMain/kotlin/su/reya/coop/App.kt | 9 +- gradle/libs.versions.toml | 2 + shared/build.gradle.kts | 4 + .../kotlin/su/reya/coop/NostrViewModel.kt | 44 ++++++++- .../su/reya/coop/blossom/BlossomClient.kt | 80 ++++++++++++++++ .../kotlin/su/reya/coop/blossom/Bud01.kt | 92 +++++++++++++++++++ .../kotlin/su/reya/coop/blossom/Bud02.kt | 27 ++++++ 7 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/blossom/BlossomClient.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud01.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud02.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 9be31f4..7730b2c 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -124,7 +124,14 @@ fun App(dbPath: String) { isLoading = isCreating, onBack = { navController.popBackStack() }, onSave = { name, bio, uri -> - viewModel.createIdentity(name, bio, uri?.toString()) + 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) } ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc494c5..d2a1b5d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,6 +41,8 @@ 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/shared/build.gradle.kts b/shared/build.gradle.kts index 796f8a2..bce9a38 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 { @@ -27,8 +28,11 @@ kotlin { implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") implementation("su.reya:nostr-sdk-kmp:0.1.5") + 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) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index ca6d014..b2d6269 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -2,6 +2,9 @@ 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.flow.MutableStateFlow @@ -11,11 +14,14 @@ 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.Keys import rust.nostr.sdk.Metadata import rust.nostr.sdk.NostrConnect import rust.nostr.sdk.NostrConnectUri +import rust.nostr.sdk.NostrSigner import rust.nostr.sdk.PublicKey +import su.reya.coop.blossom.BlossomClient import su.reya.coop.storage.SecretStorage import kotlin.time.Clock import kotlin.time.Duration @@ -47,6 +53,10 @@ class NostrViewModel( private fun showError(message: String) { viewModelScope.launch { _errorEvents.send(message) + + if (isCreating.value) { + _isCreating.value = false + } } } @@ -180,17 +190,47 @@ class NostrViewModel( return keys } - fun createIdentity(name: String, bio: String, picture: String?) { + fun createIdentity( + name: String, + bio: String, + picture: ByteArray?, + contentType: String? + ) { 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 = NostrSigner.keys(keys) + ) + + avatarUrl = descriptor?.url ?: "" + } + // Create identity - nostr.createIdentity(keys, name, bio, picture) + nostr.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl) // Save secret to the secret storage secretStore.set("user_signer", secret) 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..42255a3 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/blossom/BlossomClient.kt @@ -0,0 +1,80 @@ +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.EventBuilder +import rust.nostr.sdk.NostrSigner +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: NostrSigner? = 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: NostrSigner, authz: BlossomAuthorization): HeaderValue { + val authEvent = EventBuilder.blossomAuth(authz).sign(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 -- 2.49.1 From b0eb083284db5530cd715754746b46350f7b47c8 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 10 May 2026 08:08:29 +0700 Subject: [PATCH 19/43] update --- composeApp/build.gradle.kts | 1 - shared/build.gradle.kts | 1 + .../commonMain/kotlin/su/reya/coop/Nostr.kt | 117 ++++++++++-------- .../commonMain/kotlin/su/reya/coop/Room.kt | 45 ++++++- 4 files changed, 112 insertions(+), 52 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ec9e031..c6499bd 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -20,7 +20,6 @@ kotlin { implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.activity.compose) implementation("androidx.navigation:navigation-compose:2.8.8") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0") 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") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index bce9a38..bd719e1 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -27,6 +27,7 @@ kotlin { commonMain.dependencies { 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.1.5") implementation("com.squareup.okio:okio:3.16.2") implementation(libs.ktor.client.core) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 7a26456..bdb4157 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -73,15 +73,20 @@ class Nostr { .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) .build() + // Bootstrap relays client?.addRelay(RelayUrl.parse("wss://relay.primal.net")) client?.addRelay(RelayUrl.parse("wss://user.kindpag.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("10s")) } catch (e: Exception) { - println("Failed to initialize client: ${e.message}") + throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e) } } @@ -104,7 +109,7 @@ class Nostr { // Fetch metadata for current user getUserMetadata() } catch (e: Exception) { - println("Failed to set signer: ${e.message}") + throw IllegalStateException("Failed to set key signer: ${e.message}", e) } } @@ -116,7 +121,7 @@ class Nostr { // Fetch metadata for current user getUserMetadata() } catch (e: Exception) { - println("Failed to set remote signer: ${e.message}") + throw IllegalStateException("Failed to set remote signer: ${e.message}", e) } } @@ -124,59 +129,73 @@ class Nostr { return try { signer?.getPublicKey()?.toBech32() == event.author().toBech32() } catch (e: Exception) { + println("Failed to check if event is signed by user: ${e.message}") false } } suspend fun getUserMetadata() { - // Get the latest metadata event - val metadataFilter = - Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.METADATA)) + if (userPubkey == null) return + + try { + // Get the latest metadata event + val metadataFilter = + Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.METADATA)) - // Get the latest contact list event - val contactFilter = - Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.CONTACT_LIST)) + // Get the latest contact list event + val contactFilter = + Filter().author(userPubkey!!).limit(1u) + .kind(Kind.fromStd(KindStandard.CONTACT_LIST)) - // Get the latest messaging relay list event - val msgRelayFilter = - Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.INBOX_RELAYS)) + // Get the latest messaging relay list event + val msgRelayFilter = + Filter().author(userPubkey!!).limit(1u) + .kind(Kind.fromStd(KindStandard.INBOX_RELAYS)) - // Construct a target that includes all filters - val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter)) - val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) + // 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) + 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) { - val userPubkey = signer?.getPublicKey() ?: return - val relays = extractMessagingRelayList(msgRelayList) + try { + val userPubkey = signer?.getPublicKey() ?: return + val relays = extractMessagingRelayList(msgRelayList) + + // Ensure relay connections + relays.forEach { relay -> + client?.addRelay(relay, RelayCapabilities.none()) + client?.connectRelay(relay) + } + + // Construct a filter for gift wrap events + val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(userPubkey) + val target = mutableMapOf>() + relays.forEach { relay -> + target[relay] = listOf(filter) + } + + client?.subscribe( + target = ReqTarget.manual(target), + id = "user-messages", + closeOn = null + ) + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch user messages: ${e.message}", e) - // Ensure relay connections - relays.forEach { relay -> - client?.addRelay(relay, RelayCapabilities.none()) - client?.connectRelay(relay) } - - // Construct a filter for gift wrap events - val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(userPubkey) - val target = mutableMapOf>() - relays.forEach { relay -> - target[relay] = listOf(filter) - } - - client?.subscribe( - target = ReqTarget.manual(target), - id = "user-messages", - closeOn = null - ) } suspend fun handleNotifications(onMetadataUpdate: (PublicKey, Metadata) -> Unit) { val now = Timestamp.now() val processedEvent = mutableSetOf() val notifications = client?.notifications() ?: return - + while (true) { val notification = notifications.next() ?: continue @@ -247,9 +266,9 @@ class Nostr { return event?.content()?.let { UnsignedEvent.fromJson(it) } } catch (e: Exception) { - // TODO: log error + println("Failed to get cached rumor: ${e.message}") + return null } - return null } private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { @@ -263,7 +282,7 @@ class Nostr { client?.database()?.saveEvent(event) client?.database()?.saveEvent(rumor.signWithKeys(rngKeys)) } catch (e: Exception) { - // TODO: log error + println("Failed to set cached rumor: ${e.message}") } } @@ -289,7 +308,7 @@ class Nostr { // Return the rumor return rumor } catch (e: Exception) { - // TODO: log error + println("Failed to unwrap gift: ${e.message}") continue } } @@ -378,13 +397,13 @@ class Nostr { } suspend fun fetchMetadataBatch(keys: List) { - val filter = - Filter() - .kind(Kind.fromStd(KindStandard.METADATA)) - .authors(keys) - .limit(keys.size.toULong()) - val target = - ReqTarget.manual(mapOf(RelayUrl.parse("wss://user.kindpag.es") to listOf(filter))) + val filter = Filter() + .kind(Kind.fromStd(KindStandard.METADATA)) + .authors(keys) + .limit(keys.size.toULong()) + + val metadataRelay = RelayUrl.parse("wss://user.kindpag.es") + val target = ReqTarget.manual(mapOf(metadataRelay to listOf(filter))) val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) client?.subscribe(target = target, id = "metadata-reqs", closeOn = opts) @@ -427,7 +446,7 @@ class Nostr { // Set the room kind based on interaction status if (isInteracting || isContact) { - room.kind(RoomKind.Ongoing) + room.setKind(RoomKind.Ongoing) } rooms.add(room) @@ -436,7 +455,7 @@ class Nostr { return rooms } catch (e: Exception) { println("Failed to get chat rooms: ${e.message}") + return null } - return null } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index 86c0f1a..503e5ca 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -1,9 +1,13 @@ package su.reya.coop +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime import rust.nostr.sdk.Event import rust.nostr.sdk.PublicKey import rust.nostr.sdk.TagKind import rust.nostr.sdk.Timestamp +import kotlin.time.Clock +import kotlin.time.Instant enum class RoomKind { Ongoing, @@ -40,7 +44,7 @@ data class Room( 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 + // 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()) @@ -56,9 +60,21 @@ data class Room( } } - fun kind(kind: RoomKind): Room { + 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 isGroup(): Boolean { + return members.size > 1 + } } fun Event.roomId(): Long { @@ -74,3 +90,28 @@ fun Event.roomId(): Long { 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" + } + } +} -- 2.49.1 From a4bd1c29001205cc313d11a68d6c4ee4359ab35f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 10 May 2026 20:35:19 +0700 Subject: [PATCH 20/43] update nostr sdk --- composeApp/build.gradle.kts | 2 +- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 121 ++++++++++++++--- gradle/libs.versions.toml | 2 +- shared/build.gradle.kts | 2 +- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 126 ++++++++++-------- .../kotlin/su/reya/coop/NostrViewModel.kt | 44 ++++-- .../commonMain/kotlin/su/reya/coop/Signer.kt | 55 ++++++++ .../su/reya/coop/blossom/BlossomClient.kt | 11 +- 8 files changed, 268 insertions(+), 95 deletions(-) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/Signer.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index c6499bd..b66a51a 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -25,7 +25,7 @@ kotlin { 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.1.2") + implementation("su.reya:nostr-sdk-kmp:0.2.1") } commonMain.dependencies { implementation(libs.compose.runtime) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index aa9bcf2..a90aed2 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -1,61 +1,82 @@ package su.reya.coop.screens +import android.content.ClipData 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton 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.material3.rememberModalBottomSheetState +import androidx.compose.material3.toShape import androidx.compose.runtime.Composable 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.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.toClipEntry import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_avatar import coop.composeapp.generated.resources.ic_search +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Room +import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun HomeScreen(onOpenChat: (Long) -> Unit) { + val clipboard = LocalClipboard.current + val snackbarHostState = LocalSnackbarHostState.current val viewModel = LocalNostrViewModel.current - val userProfile by viewModel.getUserProfile().collectAsState(initial = null) + val scope = rememberCoroutineScope() + + 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 sheetState = rememberModalBottomSheetState() var showBottomSheet by remember { mutableStateOf(false) } Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, containerColor = MaterialTheme.colorScheme.surfaceContainer, topBar = { TopAppBar( @@ -141,27 +162,33 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) { 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" - Column(modifier = Modifier.padding(16.dp)) { - ListItem( - headlineContent = { - Text( - text = userName, - style = MaterialTheme.typography.titleMediumEmphasized - ) - }, - leadingContent = { + 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 + ) { if (userProfile?.asRecord()?.picture != null) { AsyncImage( model = userProfile?.asRecord()?.picture, contentDescription = "User Avatar", - modifier = Modifier - .size(32.dp) - .clip(CircleShape), + modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop ) } else { @@ -171,12 +198,36 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) { ) } } - ) - HorizontalDivider() - Button( - onClick = { viewModel.logout() }, - content = { Text("Logout") } - ) + Spacer(modifier = Modifier.size(8.dp)) + Box( + contentAlignment = Alignment.Center + ) { + Text( + text = userName, + style = MaterialTheme.typography.titleLargeEmphasized, + ) + } + Spacer(modifier = Modifier.size(8.dp)) + Box( + contentAlignment = Alignment.Center + ) { + OutlinedButton( + onClick = { + scope.launch { + if (pubkey != null) { + val text = pubkey.toBech32(); + val entry = ClipData.newPlainText("text", text) + clipboard.setClipEntry(entry.toClipEntry()) + } + } + }, + ) { + Text(text = shortPubkey) + } + } + } + Spacer(modifier = Modifier.size(16.dp)) + BottomMenuList() } } } @@ -204,3 +255,31 @@ fun ChatRoom(room: Room, onClick: () -> Unit) { ) } +val defaultMenuList = listOf( + "Messaging Relays", + "Spam Filter", + "Contacts", + "Settings", + "About" +) + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun BottomMenuList() { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + defaultMenuList.forEachIndexed { index, item -> + SegmentedListItem( + checked = false, + onCheckedChange = { }, + shapes = ListItemDefaults.segmentedShapes( + index = index, + count = defaultMenuList.size + ), + content = { Text(text = item) }, + ) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d2a1b5d..1a4e31f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.2.0" +agp = "9.2.1" android-compileSdk = "36" android-minSdk = "24" android-targetSdk = "36" diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index bd719e1..583be7f 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -28,7 +28,7 @@ kotlin { 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.1.5") + implementation("su.reya:nostr-sdk-kmp:0.2.1") implementation("com.squareup.okio:okio:3.16.2") implementation(libs.ktor.client.core) implementation(libs.ktor.client.websockets) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index bdb4157..b4ab998 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -2,6 +2,8 @@ package su.reya.coop import io.ktor.client.HttpClient import io.ktor.client.plugins.websocket.WebSockets +import rust.nostr.sdk.AckPolicy +import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.Client import rust.nostr.sdk.ClientBuilder import rust.nostr.sdk.ClientNotification @@ -17,10 +19,8 @@ import rust.nostr.sdk.KindStandard import rust.nostr.sdk.LogLevel import rust.nostr.sdk.Metadata import rust.nostr.sdk.MetadataRecord -import rust.nostr.sdk.NostrConnect import rust.nostr.sdk.NostrDatabase import rust.nostr.sdk.NostrGossip -import rust.nostr.sdk.NostrSigner import rust.nostr.sdk.PublicKey import rust.nostr.sdk.RelayCapabilities import rust.nostr.sdk.RelayMessageEnum @@ -28,24 +28,23 @@ 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.SleepWhenIdle import rust.nostr.sdk.SubscribeAutoCloseOptions import rust.nostr.sdk.Tag import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnwrappedGift -import rust.nostr.sdk.extractMessagingRelayList import rust.nostr.sdk.initLogger +import rust.nostr.sdk.nip17ExtractRelayList import kotlin.time.Duration class Nostr { var client: Client? = null private set - var signer: NostrSigner? = null + var signer: UniversalSigner = UniversalSigner(Keys.generate()) private set - var deviceSigner: NostrSigner? = null - private set - var userPubkey: PublicKey? = null + var deviceSigner: AsyncNostrSigner? = null private set var contactList: List = emptyList() private set @@ -64,12 +63,13 @@ class Nostr { client = ClientBuilder() + .signer(signer) .websocketTransport(CoopWebSocketClient(httpClient)) .database(lmdb) .gossip(gossip) .gossipConfig(GossipConfig().noBackgroundRefresh()) .verifySubscriptions(false) - .automaticAuthentication(false) + .automaticAuthentication(true) .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) .build() @@ -84,7 +84,7 @@ class Nostr { ) // Connect to all bootstrap relays and wait for all connections to be established - client?.connect(Duration.parse("10s")) + client?.connect(Duration.parse("3s")) } catch (e: Exception) { throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e) } @@ -95,39 +95,23 @@ class Nostr { } fun exit() { - signer = null deviceSigner = null - userPubkey = null contactList = emptyList() } - suspend fun setKeySigner(keys: Keys) { + suspend fun setSigner(keys: AsyncNostrSigner) { try { - signer = NostrSigner.keys(keys) - userPubkey = signer?.getPublicKey() - + signer.switch(keys) // Fetch metadata for current user getUserMetadata() } catch (e: Exception) { - throw IllegalStateException("Failed to set key signer: ${e.message}", e) + throw IllegalStateException("Failed to set signer: ${e.message}", e) } } - suspend fun setRemoteSigner(remote: NostrConnect) { - try { - signer = NostrSigner.nostrConnect(remote) - userPubkey = signer?.getPublicKey() - - // Fetch metadata for current user - getUserMetadata() - } catch (e: Exception) { - throw IllegalStateException("Failed to set remote signer: ${e.message}", e) - } - } - - suspend fun isSignedByUser(event: Event): Boolean { + fun isSignedByUser(event: Event): Boolean { return try { - signer?.getPublicKey()?.toBech32() == event.author().toBech32() + signer.currentUser == event.author() } catch (e: Exception) { println("Failed to check if event is signed by user: ${e.message}") false @@ -135,22 +119,20 @@ class Nostr { } suspend fun getUserMetadata() { - if (userPubkey == null) return - try { + val author = signer.currentUser ?: throw IllegalStateException("User not signed in") + // Get the latest metadata event val metadataFilter = - Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.METADATA)) + Filter().kind(Kind.fromStd(KindStandard.METADATA)).author(author).limit(1u) // Get the latest contact list event val contactFilter = - Filter().author(userPubkey!!).limit(1u) - .kind(Kind.fromStd(KindStandard.CONTACT_LIST)) + Filter().kind(Kind.fromStd(KindStandard.CONTACT_LIST)).author(author).limit(1u) // Get the latest messaging relay list event val msgRelayFilter = - Filter().author(userPubkey!!).limit(1u) - .kind(Kind.fromStd(KindStandard.INBOX_RELAYS)) + 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)) @@ -164,17 +146,17 @@ class Nostr { suspend fun getUserMessages(msgRelayList: Event) { try { - val userPubkey = signer?.getPublicKey() ?: return - val relays = extractMessagingRelayList(msgRelayList) + val author = signer.currentUser ?: throw IllegalStateException("User not signed in") + val relays = nip17ExtractRelayList(msgRelayList) // Ensure relay connections relays.forEach { relay -> - client?.addRelay(relay, RelayCapabilities.none()) + client?.addRelay(relay) client?.connectRelay(relay) } // Construct a filter for gift wrap events - val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(userPubkey) + val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(author) val target = mutableMapOf>() relays.forEach { relay -> target[relay] = listOf(filter) @@ -182,12 +164,10 @@ class Nostr { client?.subscribe( target = ReqTarget.manual(target), - id = "user-messages", - closeOn = null + id = "user-messages" ) } catch (e: Exception) { throw IllegalStateException("Failed to fetch user messages: ${e.message}", e) - } } @@ -273,6 +253,7 @@ class Nostr { private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { if (rumor.id() == null) return + try { val rngKeys = Keys.generate() val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA); @@ -287,8 +268,6 @@ class Nostr { } private suspend fun extractRumor(event: Event): UnsignedEvent? { - if (event.kind().asStd() != KindStandard.GIFT_WRAP) return null - // Check if the rumor is already cached val cachedRumor = getCachedRumor(event.id()) if (cachedRumor != null) return cachedRumor @@ -301,7 +280,7 @@ class Nostr { for (signer in signers) { try { // TODO: custom unwrapping logic - val gift = UnwrappedGift.fromGiftWrap(signer = signer, giftWrap = event) + val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event) val rumor = gift.rumor() // Save the rumor to the database setCachedRumor(event.id(), rumor) @@ -373,27 +352,47 @@ class Nostr { // Send relay list event val relayList = getDefaultRelayList() val relayListEvent = EventBuilder.relayList(relayList).signWithKeys(keys); - client?.sendEvent(relayListEvent) + + client?.sendEvent( + event = relayListEvent, + target = SendEventTarget.broadcast(), + ackPolicy = AckPolicy.all(), + okTimeout = Duration.parse("3s") + ) // Send messaging relay list event val msgRelayList = getMsgRelayList() val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).signWithKeys(keys) - client?.sendEventNoWait(msgRelayListEvent) + + client?.sendEvent( + event = msgRelayListEvent, + target = SendEventTarget.toNip65(), + ackPolicy = AckPolicy.none() + ) // Send metadata event val metadata = Metadata.fromRecord(MetadataRecord(name = name, about = bio, picture = picture)) val metadataEvent = EventBuilder.metadata(metadata).signWithKeys(keys) - client?.sendEventNoWait(metadataEvent) + + client?.sendEvent( + event = metadataEvent, + target = SendEventTarget.toNip65(), + ackPolicy = AckPolicy.none() + ) // Send contact list event val defaultContact = listOf(Contact(publicKey = PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x"))) val contactListEvent = EventBuilder.contactList(defaultContact).signWithKeys(keys) - client?.sendEventNoWait(contactListEvent) - // Set signer - setKeySigner(keys) + client?.sendEvent( + event = contactListEvent, + target = SendEventTarget.toNip65(), + ackPolicy = AckPolicy.none() + ) + + setSigner(keys) } suspend fun fetchMetadataBatch(keys: List) { @@ -411,7 +410,7 @@ class Nostr { suspend fun getChatRooms(): Set? { try { - val userPubkey = signer?.getPublicKey() ?: return null + val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in") val kind = Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE) // Get all events sent by the user @@ -458,4 +457,23 @@ class Nostr { return null } } + + suspend fun getChatRoomMessages(members: List): List { + try { + val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in") + + val kind = Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE) + val sendFilter = Filter().kind(kind).author(userPubkey).pubkeys(members) + val recvFilter = Filter().kind(kind).pubkey(userPubkey).authors(members) + + val sendEvents = client?.database()?.query(sendFilter) + val recvEvents = client?.database()?.query(recvFilter) + + sendEvents?.merge(recvEvents!!)?.toVec() + } catch (e: Exception) { + throw IllegalStateException("Failed to get chat room messages: ${e.message}", e) + } + + return emptyList() + } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index b2d6269..2b472f7 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -15,11 +15,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json +import rust.nostr.sdk.Event import rust.nostr.sdk.Keys import rust.nostr.sdk.Metadata import rust.nostr.sdk.NostrConnect import rust.nostr.sdk.NostrConnectUri -import rust.nostr.sdk.NostrSigner import rust.nostr.sdk.PublicKey import su.reya.coop.blossom.BlossomClient import su.reya.coop.storage.SecretStorage @@ -90,7 +90,7 @@ class NostrViewModel( } } - fun requestMetadata(pubkey: PublicKey) { + private fun requestMetadata(pubkey: PublicKey) { if (seenPublicKeys.add(pubkey)) { viewModelScope.launch { metadataRequestChannel.send(pubkey) @@ -106,14 +106,10 @@ class NostrViewModel( return flow.asStateFlow() } - fun updateMetadata(pubkey: PublicKey, metadata: Metadata) { + private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) { _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata } - fun getUserProfile(): StateFlow { - return nostr.userPubkey?.let { getMetadata(it) } ?: MutableStateFlow(null).asStateFlow() - } - suspend fun initAndConnect(dbPath: String) { try { // Initialize nostr client @@ -133,6 +129,10 @@ class NostrViewModel( } } + fun currentUser(): PublicKey? { + return nostr.signer.currentUser + } + fun logout() { viewModelScope.launch { _emptySecret.value = true @@ -159,14 +159,14 @@ class NostrViewModel( // Handle different signer types if (secret.startsWith("nsec1")) { val keys = Keys.parse(secret) - nostr.setKeySigner(keys) + 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.setRemoteSigner(remote) + nostr.setSigner(remote) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -223,7 +223,7 @@ class NostrViewModel( val descriptor = blossom.upload( file = picture, contentType = contentType, - signer = NostrSigner.keys(keys) + signer = keys ) avatarUrl = descriptor?.url ?: "" @@ -247,7 +247,7 @@ class NostrViewModel( viewModelScope.launch { if (secret.startsWith("nsec1")) { val keys = Keys.parse(secret) - nostr.setKeySigner(keys) + nostr.setSigner(keys) secretStore.set("user_signer", secret) // Set an empty secret state _emptySecret.value = false @@ -258,7 +258,7 @@ class NostrViewModel( val timeout = Duration.parse("50s") // 50 seconds timeout val remote = NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null) - nostr.setRemoteSigner(remote) + nostr.setSigner(remote) secretStore.set("user_signer", secret) // Set an empty secret state _emptySecret.value = false @@ -281,6 +281,19 @@ class NostrViewModel( } } + suspend fun getChatRoomMessages(roomId: Long): List { + try { + val room = chatRooms.value.firstOrNull { it.id == roomId } ?: return emptyList() + val members = room.members + + return nostr.getChatRoomMessages(members.toList()) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + + return emptyList() + } + override fun onCleared() { super.onCleared() // Ensure all relays are disconnect @@ -290,4 +303,9 @@ class NostrViewModel( } } } -} \ No newline at end of file +} + +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/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 index 42255a3..6ba145b 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/blossom/BlossomClient.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/blossom/BlossomClient.kt @@ -10,8 +10,8 @@ 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.NostrSigner import rust.nostr.sdk.Timestamp import kotlin.io.encoding.Base64 import kotlin.time.Duration @@ -23,7 +23,7 @@ class BlossomClient( suspend fun upload( file: ByteArray, contentType: String? = null, - signer: NostrSigner? = null + signer: AsyncNostrSigner? = null ): BlobDescriptor? { val url = "$url/upload" val hash = file.toByteString().sha256().hex() @@ -71,8 +71,11 @@ class BlossomClient( ) } - suspend fun buildAuthHeader(signer: NostrSigner, authz: BlossomAuthorization): HeaderValue { - val authEvent = EventBuilder.blossomAuth(authz).sign(signer) + 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) -- 2.49.1 From 6f7c7ccd63581c15192971e1bbefb20d232a6263 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 11 May 2026 07:28:12 +0700 Subject: [PATCH 21/43] update chat room --- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 57 ++++++++++++++++++- .../commonMain/kotlin/su/reya/coop/Room.kt | 6 +- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index a90aed2..c9ce40d 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -239,16 +239,69 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ChatRoom(room: Room, onClick: () -> Unit) { - val title = room.subject ?: "Room" + val viewModel = LocalNostrViewModel.current + + val memberMetadataList = room.members.map { pubkey -> + viewModel.getMetadata(pubkey).collectAsState() + } + + val displayName = if (!room.subject.isNullOrBlank()) { + room.subject + } else if (room.isGroup()) { + val profiles = memberMetadataList.map { it.value?.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 firstMember = room.members.firstOrNull() + val profile = memberMetadataList.firstOrNull()?.value?.asRecord() + profile?.name ?: profile?.displayName ?: firstMember?.short() ?: "Unknown" + } + + val firstMemberMetadata by if (room.members.isNotEmpty()) { + viewModel.getMetadata(room.members.first()).collectAsState() + } else { + remember { mutableStateOf(null) } + } + val picture = firstMemberMetadata?.asRecord()?.picture ListItem( modifier = Modifier.clickable { onClick }, + leadingContent = { + if (!picture.isNullOrBlank()) { + AsyncImage( + model = picture, + contentDescription = "Room Avatar", + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + ) + } else { + Icon( + painter = painterResource(Res.drawable.ic_avatar), + contentDescription = "User" + ) + } + }, headlineContent = { Text( - text = title, + text = displayName ?: "Unknown", style = MaterialTheme.typography.titleMediumEmphasized ) }, + supportingContent = { + if (!room.lastMessage.isNullOrBlank()) { + Text( + text = room.lastMessage!!, + style = MaterialTheme.typography.bodyMedium + ) + } + }, colors = ListItemDefaults.colors( containerColor = MaterialTheme.colorScheme.surface ) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index 503e5ca..7ccb4a8 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -23,7 +23,8 @@ data class Room( val createdAt: Timestamp, val subject: String?, val members: Set, - val kind: RoomKind = RoomKind.default() + val kind: RoomKind = RoomKind.default(), + val lastMessage: String? = null ) : Comparable { override fun hashCode(): Int = id.hashCode() @@ -55,7 +56,8 @@ data class Room( id = id, createdAt = createdAt, subject = subject, - members = pubkeys as Set + members = pubkeys as Set, + lastMessage = rumor.content() ) } } -- 2.49.1 From 5e2dfd447fba3b6036dcf2d5e93b9caea006079b Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 11 May 2026 14:28:52 +0700 Subject: [PATCH 22/43] update chat screen --- .../androidMain/kotlin/su/reya/coop/App.kt | 6 +- .../kotlin/su/reya/coop/screens/ChatScreen.kt | 224 +++++++++++++++++- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 34 +-- .../kotlin/su/reya/coop/shared/RoomHelper.kt | 33 +++ .../commonMain/kotlin/su/reya/coop/Nostr.kt | 39 +-- .../kotlin/su/reya/coop/NostrViewModel.kt | 7 +- 6 files changed, 294 insertions(+), 49 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 7730b2c..82ec4e0 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -130,7 +130,6 @@ fun App(dbPath: String) { input.readBytes() } } - viewModel.createIdentity(name, bio, picture, contentType) } ) @@ -142,7 +141,10 @@ fun App(dbPath: String) { } composable { backStackEntry -> val chat: Screen.Chat = backStackEntry.toRoute() - ChatScreen(id = chat.id) + ChatScreen( + id = chat.id, + onBack = { navController.popBackStack() }, + ) } } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index bc9f2f3..15264a4 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -1,15 +1,233 @@ package su.reya.coop.screens import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.imePadding +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.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.Color +import androidx.compose.ui.layout.ContentScale +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_avatar +import org.jetbrains.compose.resources.painterResource +import rust.nostr.sdk.Event +import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.shared.displayNameFlow +import su.reya.coop.shared.pictureFlow @Composable -fun ChatScreen(id: Long) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Chat Screen (ID: $id)") +fun ChatScreen( + id: Long, + onBack: () -> Unit, +) { + val snackbarHostState = LocalSnackbarHostState.current + val viewModel = LocalNostrViewModel.current + val room = viewModel.getChatRoom(id) + 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 messages by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + + LaunchedEffect(id) { + loading = true + messages = viewModel.getChatRoomMessages(id) + loading = false + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Box { + if (!picture.isNullOrBlank()) { + AsyncImage( + model = picture, + contentDescription = "Room Avatar", + modifier = Modifier + .size(32.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + } else { + Icon( + painter = painterResource(Res.drawable.ic_avatar), + contentDescription = "User" + ) + } + } + 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), + ) { + if (loading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + LoadingIndicator() + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(bottom = innerPadding.calculateBottomPadding()) + ) { + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentPadding = PaddingValues(16.dp), + reverseLayout = true + ) { + items(messages.toList(), key = { it.id().toBech32() }) { event -> + ChatMessage(event) + } + } + ChatInput( + value = text, + onValueChange = { text = it }, + onSend = { + // TODO: Implement send logic + text = "" + } + ) + } + } + } + } + ) +} + +@Composable +fun ChatMessage( + event: Event +) { + val viewModel = LocalNostrViewModel.current + val currentUser = viewModel.currentUser() + val isMine = event.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 alignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart + val containerColor = + if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer + val contentColor = + if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + contentAlignment = alignment + ) { + Surface( + color = containerColor, + contentColor = contentColor, + shape = bubbleShape, + modifier = Modifier.widthIn(max = 280.dp) + ) { + Text( + text = event.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) + .imePadding(), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + value = value, + onValueChange = onValueChange, + placeholder = { Text("Message") }, + modifier = Modifier.weight(1f), + shape = CircleShape, + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ) + ) + } } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index c9ce40d..0e5fd51 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -56,6 +56,8 @@ import org.jetbrains.compose.resources.painterResource import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Room +import su.reya.coop.shared.displayNameFlow +import su.reya.coop.shared.pictureFlow import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @@ -240,37 +242,11 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) { @Composable fun ChatRoom(room: Room, onClick: () -> Unit) { val viewModel = LocalNostrViewModel.current - - val memberMetadataList = room.members.map { pubkey -> - viewModel.getMetadata(pubkey).collectAsState() - } - - val displayName = if (!room.subject.isNullOrBlank()) { - room.subject - } else if (room.isGroup()) { - val profiles = memberMetadataList.map { it.value?.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 firstMember = room.members.firstOrNull() - val profile = memberMetadataList.firstOrNull()?.value?.asRecord() - profile?.name ?: profile?.displayName ?: firstMember?.short() ?: "Unknown" - } - - val firstMemberMetadata by if (room.members.isNotEmpty()) { - viewModel.getMetadata(room.members.first()).collectAsState() - } else { - remember { mutableStateOf(null) } - } - val picture = firstMemberMetadata?.asRecord()?.picture + 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 }, + modifier = Modifier.clickable(onClick = onClick), leadingContent = { if (!picture.isNullOrBlank()) { AsyncImage( 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/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index b4ab998..aa788f8 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -182,9 +182,8 @@ class Nostr { when (notification) { is ClientNotification.Message -> { val relayUrl = notification.relayUrl - val message = notification.message.asEnum() - - when (message) { + + when (val message = notification.message.asEnum()) { is RelayMessageEnum.EventMsg -> { val event = message.event @@ -396,16 +395,29 @@ class Nostr { } suspend fun fetchMetadataBatch(keys: List) { - val filter = Filter() - .kind(Kind.fromStd(KindStandard.METADATA)) - .authors(keys) - .limit(keys.size.toULong()) + try { + val limit = keys.size.toULong(); + val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) - val metadataRelay = RelayUrl.parse("wss://user.kindpag.es") - val target = ReqTarget.manual(mapOf(metadataRelay to listOf(filter))) - val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) + // Construct a filter for metadata events + val filter = Filter() + .kind(Kind.fromStd(KindStandard.METADATA)) + .authors(keys) + .limit(limit) - client?.subscribe(target = target, id = "metadata-reqs", closeOn = opts) + // Construct a target that includes all filters + val target = + ReqTarget.manual( + mapOf( + RelayUrl.parse("wss://user.kindpag.es") to listOf(filter), + RelayUrl.parse("wss://relay.primal.net") to listOf(filter) + ) + ) + + client?.subscribe(target = target, id = "metadata-reqs", closeOn = opts) + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e) + } } suspend fun getChatRooms(): Set? { @@ -468,12 +480,11 @@ class Nostr { val sendEvents = client?.database()?.query(sendFilter) val recvEvents = client?.database()?.query(recvFilter) + val events = sendEvents?.merge(recvEvents!!)?.toVec() - sendEvents?.merge(recvEvents!!)?.toVec() + return events ?: emptyList() } catch (e: Exception) { throw IllegalStateException("Failed to get chat room messages: ${e.message}", e) } - - return emptyList() } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 2b472f7..97e590c 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -80,7 +80,7 @@ class NostrViewModel( } val now = Clock.System.now().toEpochMilliseconds() - if (batch.size >= 20 || (now - lastFlushTime) >= timeout || nextKey == null) { + if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) { val keysToRequest = batch.toList() batch.clear() nostr.fetchMetadataBatch(keysToRequest) @@ -271,6 +271,11 @@ class NostrViewModel( } } + fun getChatRoom(id: Long): Room { + return chatRooms.value.firstOrNull { it.id == id } + ?: throw IllegalArgumentException("Room not found") + } + fun getChatRooms() { viewModelScope.launch { try { -- 2.49.1 From 428a7ef7afc1deed9bec5050c8e70339e66d2fd2 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 12 May 2026 08:51:54 +0700 Subject: [PATCH 23/43] show message time --- .../kotlin/su/reya/coop/screens/ChatScreen.kt | 30 ++++++++++------ .../kotlin/su/reya/coop/screens/HomeScreen.kt | 21 ++++++++--- .../commonMain/kotlin/su/reya/coop/Room.kt | 35 +++++++++++++++++++ 3 files changed, 71 insertions(+), 15 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index 15264a4..b5a1357 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -48,6 +48,7 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.Event import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.humanReadable import su.reya.coop.shared.displayNameFlow import su.reya.coop.shared.pictureFlow @@ -177,9 +178,9 @@ fun ChatMessage( RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 4.dp, bottomEnd = 20.dp) } - val alignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart val containerColor = if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer + val contentColor = if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer @@ -187,19 +188,26 @@ fun ChatMessage( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), - contentAlignment = alignment + contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart ) { - Surface( - color = containerColor, - contentColor = contentColor, - shape = bubbleShape, - modifier = Modifier.widthIn(max = 280.dp) - ) { + Column { Text( - text = event.content(), - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - style = MaterialTheme.typography.bodyMedium + text = event.createdAt().humanReadable(), + style = MaterialTheme.typography.labelSmall, ) + Spacer(modifier = Modifier.size(4.dp)) + Surface( + color = containerColor, + contentColor = contentColor, + shape = bubbleShape, + modifier = Modifier.widthIn(max = 280.dp) + ) { + Text( + text = event.content(), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodyMedium + ) + } } } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 0e5fd51..05a71cb 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -5,6 +5,7 @@ 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 @@ -56,6 +57,7 @@ import org.jetbrains.compose.resources.painterResource import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Room +import su.reya.coop.ago import su.reya.coop.shared.displayNameFlow import su.reya.coop.shared.pictureFlow import su.reya.coop.short @@ -265,10 +267,21 @@ fun ChatRoom(room: Room, onClick: () -> Unit) { } }, headlineContent = { - Text( - text = displayName ?: "Unknown", - style = MaterialTheme.typography.titleMediumEmphasized - ) + 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()) { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index 7ccb4a8..1b2a7cb 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -1,6 +1,9 @@ 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.Event import rust.nostr.sdk.PublicKey @@ -117,3 +120,35 @@ fun Timestamp.ago(): String { } } } + +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" + } + } +} -- 2.49.1 From b0fcb05cdf37efe473a4c90db48c5f9723c79b9c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 13 May 2026 15:37:09 +0700 Subject: [PATCH 24/43] update nostr class --- composeApp/build.gradle.kts | 2 +- .../composeResources/drawable/ic_send.xml | 9 + .../kotlin/su/reya/coop/screens/ChatScreen.kt | 76 +++++++-- shared/build.gradle.kts | 2 +- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 155 ++++++++++++++++-- .../kotlin/su/reya/coop/NostrViewModel.kt | 51 +++++- .../commonMain/kotlin/su/reya/coop/Room.kt | 4 + 7 files changed, 266 insertions(+), 33 deletions(-) create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_send.xml diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b66a51a..ae735f1 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -25,7 +25,7 @@ kotlin { 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.1") + implementation("su.reya:nostr-sdk-kmp:0.2.2") } commonMain.dependencies { implementation(libs.compose.runtime) 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/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index b5a1357..ff08960 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -2,12 +2,15 @@ package su.reya.coop.screens 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.imePadding +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn @@ -15,8 +18,10 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape 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 @@ -31,6 +36,7 @@ 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 @@ -39,16 +45,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign 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_avatar +import coop.composeapp.generated.resources.ic_send import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.Event import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.humanReadable +import su.reya.coop.roomId import su.reya.coop.shared.displayNameFlow import su.reya.coop.shared.pictureFlow @@ -59,18 +68,41 @@ fun ChatScreen( ) { val snackbarHostState = LocalSnackbarHostState.current val viewModel = LocalNostrViewModel.current + val room = viewModel.getChatRoom(id) 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 messages by remember { mutableStateOf>(emptyList()) } var loading by remember { mutableStateOf(true) } + val messages = remember { mutableStateListOf() } + + fun setLoading(value: Boolean) { + loading = value + } + LaunchedEffect(id) { - loading = true - messages = viewModel.getChatRoomMessages(id) - loading = false + // Start loading spinner + setLoading(true) + + // Get messages + val initialMessages = viewModel.getChatRoomMessages(id) + messages.clear() + messages.addAll(initialMessages) + + // Get msg relays for each member + viewModel.chatRoomConnect(id) + + // Stop loading spinner + setLoading(false) + + // Handle new messages + viewModel.newEvents.collect { event -> + if (event.roomId() == id) { + messages.add(0, event) + } + } } Scaffold( @@ -153,7 +185,7 @@ fun ChatScreen( value = text, onValueChange = { text = it }, onSend = { - // TODO: Implement send logic + viewModel.sendMessage(id, text) text = "" } ) @@ -190,10 +222,13 @@ fun ChatMessage( .padding(vertical = 4.dp), contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart ) { - Column { + Column( + horizontalAlignment = if (isMine) Alignment.End else Alignment.Start + ) { Text( text = event.createdAt().humanReadable(), style = MaterialTheme.typography.labelSmall, + textAlign = if (isMine) TextAlign.End else TextAlign.Start, ) Spacer(modifier = Modifier.size(4.dp)) Surface( @@ -218,24 +253,41 @@ fun ChatInput( onValueChange: (String) -> Unit, onSend: () -> Unit ) { + Surface(modifier = Modifier.fillMaxWidth()) { Row( modifier = Modifier .padding(horizontal = 16.dp, vertical = 8.dp) - .imePadding(), - verticalAlignment = Alignment.CenterVertically + .height(IntrinsicSize.Min), + verticalAlignment = Alignment.Bottom ) { TextField( value = value, onValueChange = onValueChange, placeholder = { Text("Message") }, - modifier = Modifier.weight(1f), - shape = CircleShape, + 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/shared/build.gradle.kts b/shared/build.gradle.kts index 583be7f..5808604 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -28,7 +28,7 @@ kotlin { 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.1") + implementation("su.reya:nostr-sdk-kmp:0.2.2") implementation("com.squareup.okio:okio:3.16.2") implementation(libs.ktor.client.core) implementation(libs.ktor.client.websockets) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index aa788f8..470572f 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -2,6 +2,10 @@ package su.reya.coop import io.ktor.client.HttpClient import io.ktor.client.plugins.websocket.WebSockets +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import rust.nostr.sdk.AckPolicy import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.Client @@ -32,10 +36,12 @@ import rust.nostr.sdk.SendEventTarget 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.initLogger +import rust.nostr.sdk.makePrivateMsgAsync import rust.nostr.sdk.nip17ExtractRelayList import kotlin.time.Duration @@ -46,6 +52,8 @@ class Nostr { private set var deviceSigner: AsyncNostrSigner? = null private set + var msgRelayList: Map> = emptyMap() + private set var contactList: List = emptyList() private set @@ -94,7 +102,8 @@ class Nostr { client?.shutdown() } - fun exit() { + suspend fun exit() { + signer.switch(Keys.generate()) deviceSigner = null contactList = emptyList() } @@ -164,17 +173,23 @@ class Nostr { client?.subscribe( target = ReqTarget.manual(target), - id = "user-messages" + id = "messages" ) } catch (e: Exception) { throw IllegalStateException("Failed to fetch user messages: ${e.message}", e) } } - suspend fun handleNotifications(onMetadataUpdate: (PublicKey, Metadata) -> Unit) { + suspend fun handleNotifications( + onMetadataUpdate: (PublicKey, Metadata) -> Unit, + onEose: () -> Unit, + onNewMessage: (Event) -> Unit + ) = coroutineScope { val now = Timestamp.now() val processedEvent = mutableSetOf() - val notifications = client?.notifications() ?: return + val notifications = client?.notifications() ?: return@coroutineScope + + var eoseTrackerJob: Job? = null while (true) { val notification = notifications.next() ?: continue @@ -182,7 +197,7 @@ class Nostr { when (notification) { is ClientNotification.Message -> { val relayUrl = notification.relayUrl - + when (val message = notification.message.asEnum()) { is RelayMessageEnum.EventMsg -> { val event = message.event @@ -204,12 +219,30 @@ class Nostr { if (isSignedByUser(event = event)) { getUserMessages(msgRelayList = event) } + // Cache the relay list for future use + setMsgRelay(pubkey = event.author(), event = event) } if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) { try { val rumor = extractRumor(event) - // TODO: Handle rumor + + // 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 + onEose() + } + + // Handle new message + rumor?.createdAt()?.asSecs()?.let { + if (it >= now.asSecs()) { + // TODO: only send unsigned event + onNewMessage(rumor.signWithKeys(Keys.generate())) + } + } } catch (e: Exception) { println("Failed to extract rumor: $e") } @@ -218,7 +251,10 @@ class Nostr { is RelayMessageEnum.EndOfStoredEvents -> { val subscriptionId = message.subscriptionId - // TODO: Handle end of stored events + + if (subscriptionId == "messages") { + onEose() + } } else -> { @@ -238,6 +274,11 @@ class Nostr { } } + private fun setMsgRelay(pubkey: PublicKey, event: Event) { + val relays = nip17ExtractRelayList(event) + msgRelayList = msgRelayList + (pubkey to relays) + } + private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? { try { val filter = Filter().identifier(giftId.toBech32()) @@ -245,16 +286,18 @@ class Nostr { return event?.content()?.let { UnsignedEvent.fromJson(it) } } catch (e: Exception) { - println("Failed to get cached rumor: ${e.message}") - return null + throw IllegalStateException("Failed to get cached rumor: ${e.message}", e) } } private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { - if (rumor.id() == null) return - try { val rngKeys = Keys.generate() + + // Ensure the rumor ID is set + val rumor = rumor.ensureId() + + // Construct a reference event val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA); val tags = listOf(Tag.identifier(giftId.toBech32()), Tag.event(rumor.id()!!)) val event = EventBuilder(kind, rumor.asJson()).tags(tags).signWithKeys(rngKeys) @@ -444,7 +487,10 @@ class Nostr { val room = Room.new(rumor = event, userPubkey = userPubkey) // Check if the room already exists - if (rooms.contains(room)) return@forEach + if (rooms.contains(room)) { + room.setCreatedAt(room.createdAt) + room.setLastMessage(room.lastMessage) + } val filter = Filter().kind(kind).author(userPubkey).pubkeys(room.members.toList()); @@ -473,18 +519,95 @@ class Nostr { suspend fun getChatRoomMessages(members: List): List { try { val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in") - val kind = Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE) - val sendFilter = Filter().kind(kind).author(userPubkey).pubkeys(members) - val recvFilter = Filter().kind(kind).pubkey(userPubkey).authors(members) + val sendFilter = Filter().kind(kind).author(userPubkey).pubkeys(members) val sendEvents = client?.database()?.query(sendFilter) + + val recvFilter = Filter().kind(kind).authors(members).pubkey(userPubkey) val recvEvents = client?.database()?.query(recvFilter) - val events = sendEvents?.merge(recvEvents!!)?.toVec() + + // Merge the events + val events = sendEvents + ?.merge(recvEvents!!) + ?.toVec() + ?.sortedByDescending { it.createdAt().asSecs() } return events ?: emptyList() } catch (e: Exception) { throw IllegalStateException("Failed to get chat room messages: ${e.message}", e) } } + + suspend fun chatRoomConnect(members: List) { + try { + members.forEach { member -> + val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) + val filter = Filter().kind(kind).author(member).limit(1u) + val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) + + client?.subscribe( + target = ReqTarget.auto(listOf(filter)), + closeOn = opts + ) + } + } catch (e: Exception) { + throw IllegalStateException("Failed to connect to chat room: ${e.message}", e) + } + } + + suspend fun sendMessage( + to: List, + content: String, + subject: String? = null, + replies: List = emptyList() + ) { + 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 to.plus(currentUser)) { + // Construct the gift wrap event + val event = makePrivateMsgAsync( + signer = signer, + receiver = receiver, + message = content, + rumorExtraTags = tags + ) + + println("Sending message to: ${receiver.toBech32()}") + + // Send the event to receiver's NIP-17 relays + client?.sendEvent( + event = event, + target = SendEventTarget.toNip17(), + ackPolicy = AckPolicy.none(), + authenticationTimeout = Duration.parse("2s") + ) + } + } catch (e: Exception) { + throw IllegalStateException("Failed to send message: ${e.message}", e) + } + } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 97e590c..ca0325d 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -7,8 +7,10 @@ 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.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 @@ -16,6 +18,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json import rust.nostr.sdk.Event +import rust.nostr.sdk.EventId import rust.nostr.sdk.Keys import rust.nostr.sdk.Metadata import rust.nostr.sdk.NostrConnect @@ -39,6 +42,9 @@ class NostrViewModel( private val _chatRooms = MutableStateFlow>(emptySet()) val chatRooms = _chatRooms.asStateFlow() + private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) + val newEvents = _newEvents.asSharedFlow() + private val _errorEvents = Channel(Channel.BUFFERED) val errorEvents = _errorEvents.receiveAsFlow() @@ -123,9 +129,19 @@ class NostrViewModel( fun startNotificationHandler() { viewModelScope.launch { - nostr.handleNotifications { pubkey, metadata -> - updateMetadata(pubkey, metadata) - } + nostr.handleNotifications( + onMetadataUpdate = { pubkey, metadata -> + updateMetadata(pubkey, metadata) + }, + onEose = { + getChatRooms() + }, + onNewMessage = { event -> + viewModelScope.launch { + _newEvents.emit(event) + } + }, + ) } } @@ -299,6 +315,35 @@ class NostrViewModel( return emptyList() } + fun chatRoomConnect(roomId: Long) { + viewModelScope.launch { + try { + val room = getChatRoom(roomId) + val members = room.members + + nostr.chatRoomConnect(members.toList()) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + 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 + ) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + override fun onCleared() { super.onCleared() // Ensure all relays are disconnect diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index 1b2a7cb..bc19239 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -77,6 +77,10 @@ data class Room( return this.copy(subject = subject) } + fun setLastMessage(message: String?): Room { + return this.copy(lastMessage = message) + } + fun isGroup(): Boolean { return members.size > 1 } -- 2.49.1 From c8be6af0fb6b99148a444eb653ca862b0ad86841 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 14 May 2026 14:44:07 +0700 Subject: [PATCH 25/43] refactor rumor cache --- composeApp/build.gradle.kts | 2 +- .../kotlin/su/reya/coop/screens/ChatScreen.kt | 14 ++-- shared/build.gradle.kts | 2 +- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 81 +++++++++---------- .../kotlin/su/reya/coop/NostrViewModel.kt | 11 +-- .../commonMain/kotlin/su/reya/coop/Room.kt | 6 +- 6 files changed, 52 insertions(+), 64 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ae735f1..4e3d9f4 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -25,7 +25,7 @@ kotlin { 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.2") + implementation("su.reya:nostr-sdk-kmp:0.2.3") } commonMain.dependencies { implementation(libs.compose.runtime) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index ff08960..a8fb6d5 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -53,7 +53,7 @@ import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_avatar import coop.composeapp.generated.resources.ic_send import org.jetbrains.compose.resources.painterResource -import rust.nostr.sdk.Event +import rust.nostr.sdk.UnsignedEvent import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.humanReadable @@ -76,7 +76,7 @@ fun ChatScreen( var text by remember { mutableStateOf("") } var loading by remember { mutableStateOf(true) } - val messages = remember { mutableStateListOf() } + val messages = remember { mutableStateListOf() } fun setLoading(value: Boolean) { loading = value @@ -177,7 +177,7 @@ fun ChatScreen( contentPadding = PaddingValues(16.dp), reverseLayout = true ) { - items(messages.toList(), key = { it.id().toBech32() }) { event -> + items(messages.toList(), key = { it.id()?.toBech32()!! }) { event -> ChatMessage(event) } } @@ -198,11 +198,11 @@ fun ChatScreen( @Composable fun ChatMessage( - event: Event + rumor: UnsignedEvent ) { val viewModel = LocalNostrViewModel.current val currentUser = viewModel.currentUser() - val isMine = event.author() == currentUser + val isMine = rumor.author() == currentUser val bubbleShape = if (isMine) { RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp) @@ -226,7 +226,7 @@ fun ChatMessage( horizontalAlignment = if (isMine) Alignment.End else Alignment.Start ) { Text( - text = event.createdAt().humanReadable(), + text = rumor.createdAt().humanReadable(), style = MaterialTheme.typography.labelSmall, textAlign = if (isMine) TextAlign.End else TextAlign.Start, ) @@ -238,7 +238,7 @@ fun ChatMessage( modifier = Modifier.widthIn(max = 280.dp) ) { Text( - text = event.content(), + text = rumor.content(), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), style = MaterialTheme.typography.bodyMedium ) diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 5808604..dd1d5c4 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -28,7 +28,7 @@ kotlin { 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.2") + 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) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 470572f..107d4cb 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay 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 @@ -33,6 +34,7 @@ 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 @@ -182,8 +184,8 @@ class Nostr { suspend fun handleNotifications( onMetadataUpdate: (PublicKey, Metadata) -> Unit, + onNewMessage: (UnsignedEvent) -> Unit, onEose: () -> Unit, - onNewMessage: (Event) -> Unit ) = coroutineScope { val now = Timestamp.now() val processedEvent = mutableSetOf() @@ -239,8 +241,7 @@ class Nostr { // Handle new message rumor?.createdAt()?.asSecs()?.let { if (it >= now.asSecs()) { - // TODO: only send unsigned event - onNewMessage(rumor.signWithKeys(Keys.generate())) + onNewMessage(rumor) } } } catch (e: Exception) { @@ -281,7 +282,7 @@ class Nostr { private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? { try { - val filter = Filter().identifier(giftId.toBech32()) + val filter = Filter().identifier(giftId.toHex()) val event = client?.database()?.query(filter)?.first() return event?.content()?.let { UnsignedEvent.fromJson(it) } @@ -292,18 +293,30 @@ class Nostr { private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { try { - val rngKeys = Keys.generate() + 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 a reference event + // 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 tags = listOf(Tag.identifier(giftId.toBech32()), Tag.event(rumor.id()!!)) - val event = EventBuilder(kind, rumor.asJson()).tags(tags).signWithKeys(rngKeys) + + val event = EventBuilder(kind, rumor.asJson()) + .tags(tags) + .build(currentUser) + .signWithKeys(Keys.generate()) client?.database()?.saveEvent(event) - client?.database()?.saveEvent(rumor.signWithKeys(rngKeys)) } catch (e: Exception) { println("Failed to set cached rumor: ${e.message}") } @@ -337,19 +350,6 @@ class Nostr { return null } - private fun conversationId(rumor: UnsignedEvent): Long { - val pubkeys: MutableList = rumor.tags().publicKeys().toMutableList() - pubkeys.add(rumor.author()) - - val uniqueSortedKeys = pubkeys - .map { it.toHex() } - .distinct() - .sorted() - - return uniqueSortedKeys.hashCode().toLong() - } - - private suspend fun getDefaultRelayList(): Map { // Construct a list of relays val relayList = mapOf( @@ -466,21 +466,19 @@ class Nostr { suspend fun getChatRooms(): Set? { try { val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in") - val kind = Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE) + val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) + val kTag = SingleLetterTag.lowercase(Alphabet.K) // Get all events sent by the user - val sendFilter = Filter().kind(kind).author(userPubkey) - val sendEvents = client?.database()?.query(sendFilter); + val filter = Filter().kind(kind).author(userPubkey).customTag(kTag, "dm") + val events = client?.database()?.query(filter) - // Get all events sent to the user - val recvFilter = Filter().kind(kind).pubkey(userPubkey) - val recvEvents = client?.database()?.query(recvFilter); - - // Collect all events - val events = sendEvents?.merge(recvEvents!!)?.toVec(); + // Collect rooms val rooms: MutableSet = mutableSetOf() events + ?.toVec() + ?.map { UnsignedEvent.fromJson(it.content()) } ?.filter { it.tags().publicKeys().isNotEmpty() } ?.sortedByDescending { it.createdAt().asSecs() } ?.forEach { event -> @@ -516,24 +514,17 @@ class Nostr { } } - suspend fun getChatRoomMessages(members: List): List { + suspend fun getChatRoomMessages(roomId: Long): List { try { - val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in") - val kind = Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE) - - val sendFilter = Filter().kind(kind).author(userPubkey).pubkeys(members) - val sendEvents = client?.database()?.query(sendFilter) - - val recvFilter = Filter().kind(kind).authors(members).pubkey(userPubkey) - val recvEvents = client?.database()?.query(recvFilter) + 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 - val events = sendEvents - ?.merge(recvEvents!!) + return events ?.toVec() - ?.sortedByDescending { it.createdAt().asSecs() } - - return events ?: emptyList() + ?.map { UnsignedEvent.fromJson(it.content()) } + ?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList() } catch (e: Exception) { throw IllegalStateException("Failed to get chat room messages: ${e.message}", e) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index ca0325d..d55ad51 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -17,13 +17,13 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json -import rust.nostr.sdk.Event 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.UnsignedEvent import su.reya.coop.blossom.BlossomClient import su.reya.coop.storage.SecretStorage import kotlin.time.Clock @@ -42,7 +42,7 @@ class NostrViewModel( private val _chatRooms = MutableStateFlow>(emptySet()) val chatRooms = _chatRooms.asStateFlow() - private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) + private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) val newEvents = _newEvents.asSharedFlow() private val _errorEvents = Channel(Channel.BUFFERED) @@ -302,12 +302,9 @@ class NostrViewModel( } } - suspend fun getChatRoomMessages(roomId: Long): List { + suspend fun getChatRoomMessages(roomId: Long): List { try { - val room = chatRooms.value.firstOrNull { it.id == roomId } ?: return emptyList() - val members = room.members - - return nostr.getChatRoomMessages(members.toList()) + return nostr.getChatRoomMessages(roomId) } catch (e: Exception) { showError("Error: ${e.message}") } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index bc19239..22b65bc 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -5,10 +5,10 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.minus import kotlinx.datetime.number import kotlinx.datetime.toLocalDateTime -import rust.nostr.sdk.Event 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 @@ -42,7 +42,7 @@ data class Room( } companion object { - fun new(rumor: Event, userPubkey: PublicKey): Room { + fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room { val id = rumor.roomId() val createdAt = rumor.createdAt() val subject = rumor.tags().find(TagKind.Subject)?.content() @@ -86,7 +86,7 @@ data class Room( } } -fun Event.roomId(): Long { +fun UnsignedEvent.roomId(): Long { // Collect the author's public key and all public keys from tags val pubkeys: MutableList = mutableListOf() pubkeys.add(this.author()) -- 2.49.1 From 620f7e0918d53fe880101dba9d6dff11ff0a9d28 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 14 May 2026 17:52:46 +0700 Subject: [PATCH 26/43] refactor avatar component --- .../composeResources/drawable/avatar.png | Bin 0 -> 12163 bytes .../kotlin/su/reya/coop/screens/ChatScreen.kt | 26 ++------ .../kotlin/su/reya/coop/screens/HomeScreen.kt | 59 ++++-------------- .../kotlin/su/reya/coop/shared/Avatar.kt | 38 +++++++++++ 4 files changed, 56 insertions(+), 67 deletions(-) create mode 100644 composeApp/src/androidMain/composeResources/drawable/avatar.png create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/shared/Avatar.kt diff --git a/composeApp/src/androidMain/composeResources/drawable/avatar.png b/composeApp/src/androidMain/composeResources/drawable/avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..8805020677ad23a68bddda772ccb90b87652bf42 GIT binary patch literal 12163 zcmXY12{@GB_rLEf7)!P+NsTg=NQf+n8A_HcqwI<)gzVasnAet6A|$&(BJ0>oWV9z) zQzDs4*|J8qG4p?Y|G#;jd1mgt=iKGov)y|?@nkC#0bVIy0Dyp*>5=0Appd^Pz{7?7 zEQWquLw;~UrcUPp5SQEhLT1Mab4Vq0{U;w@-2!w^r%ZZnoSEx4b@v|*dn-k#hi8j%)fx~?UG)@}aB3}^R5Hx$Y2XM~uj z@kF36pMlbDjMgljS$Ey@KyUP>yu+p8mPy+$#Y1C-li6znVV^k)JFK2Q#mU$CRRr#%CD<&S&7lPZnt9It=}7J zwQ3#P`L}70a$A~}A}AN)hs?0R*FVvvUupZtrqw@Djd0B{C5zcUVBeBaT)9TU}pHsyuCA_ML;JK?JR_SPZUVTLvRvQ6V^keTK#wRl5% zQ7r)k$U>yT;VmmZbgzrEPvv@I7|kg{v)sI3S)u8vb*n!{d zhNup{Em&fyNI<0v;XsJ&9QqSa>=EF;8zP;>L@=E5lXzqlM8NGMOzv!9gk}G3h(SiQ z75%$-R3{Q2WR7r3Mq)s?+?&;JqD^bi{aB;jFk}koTgFx()!5%lyy)2OTNqFs>JfPP z6;T;ntCzVgc{B<``+jxb#FZ03^Ho$5kLFGsDcV?jOBMs)Vit|>HqGzGcY70nz}DHn zCLv#0pYw!)r9``ajrK}}ms7VQ(vqCIo|q*l$OFn7W&HRwd2yt}%{q!=J{rCGP1{0?r~uUNkPa>+d=Ve`oWe7YP(t^w>7bsH*^qec+{@j@e}@`KHXI({*5 z>*XnSDhpa^_rqZXFK*#0C!r`%6ARKbRrJF-MykM>a?#7{`y}iq`Gm9fzXP3KpflMpO>b>>??|t08XbcT=VGT_>ZIrJ|-%0@3 zpYlYYcv6u7#TMe|q$i;!OTbn_uJ4ZY8e$F`knbSQv}WlGb3Mni z?IqGAWAL*|c~erUCVAD`zl;_-Ud5OK`HwB#3eG}+WLywCJ}CXBOyLMhEi%;6cUH~>xOBeEjiV7^?H@k+bpKMzy2m7Y-DCZyV0$M% zFn(m|FEAH>_nrBDOV^@+DbN~s-QVU%<$oBtvGI!dp4+SHt-@H~Tfz?y=0x0RQ42A3 zI#FK{@e9&C4bsFtj}tI7lWU(BEuYAc@KS#gTf$HOpb>A#qq&#$eUc;bJn->Dz76O! zjJgPXM4A;CT%*J7`IjpK%3s9 z;fkJ@t3MZ2X()jjm*`7wPk_1xqb*_lzVyTU=gd&6xbG;IkA0 zCNaGgn3&$61Z%*<9v^%8u4{Ba@BNh2>SV}hx5Y_e#bmCl)2F*#ukU8>=riDIP-WgA zo1!vv!2buw=CKiR(xlsx-~!9KvHfZ5;DLGbr+=pTEBL+l({Ox4Sh_Re{X&Hq3JH%7 z@)5SL=`NZ?Oc*99V2*lvBu@4i^I{LYHguvZLCo<0vkSkWxo^qAxC|no=wFi`p$;hv z^bcTQL*jVR*z$puA@TxVK2U^w+4#lv>qjXiq=RjTb6)h45-c`n|I2*N+S3yC&5hMt zzSiY3KWlsdr1_54F<#4op86;HzeldxfQiba%hUQlE#|C!WHv(QUq$Le&NIZ|h4a<# z2riPO)l6;05*r-p>pcD~wsY<9muF+dn_5FYr0Jsg4!(6_`GqQ?prvD7hE0|fRHhkp zlypq$$G>32Kt!6fXU@$z__(_%Rh%7=V%?KhRK*|MjwfQu3#z|A+$&#QAS{&v(m9Wv z{{`|Svdc;j$3|g`=NXE76FK#^DV!Tcte{c+W-)M_T~fiqb~H|>2Y??%*YMZ zUj2)oOSd)cpt(q%jKWN5j(Wh$V({aidCbI}EuQD`-ptFv%Cg_Kgie?D4XMqg#&7GX z*PZ=&{WA{6v1^|P6Bln(&2NmCGPX=)qZkJxrLE80Xl%9w$w%)jW5)BtIP_cp)CMkj z2%9#Koj*bA5(E2I+73DeG#%Ez({etNqeZvT^O0FjzK_Ue(PZuomx5KeJU!(fnw%4P z50znd=H}(*W5~SY+=?dzw@*waw@{z@P_GM`XnfygorP*K2TfrIda_V{BuX&a4mg&{ z1+IJ&WLlz<_yZ`t__f|!!4#t9$CFR*LjpYsDr48AL`JQ#l-h9tsz0MqoNf zsh62t=@w)j#=_xL{)_WS^zb!B97uxh&JOONgV2pJR_$z}a3%YfZ={QXF7*^ByP}3? ze8IrFoVPb|=V$WIp%q?kfSGR+Ob-+zo5~0r9+HDHNM%D^J`VmHE&qkO=4Ah^#_AMN>Tpsb^2ubI`uW$B@mLKBg zoVWh#?wpj6n&>(~QW>~PnEO%G`Wj!%*>T`eRujkI3wtHHX6gI-&*dCzB7x+;=*|57 zSlho!XSa|Ep@=HQNg9ez$$QW0vBt9d2u#H9+#YE(SU3eKNxb{U{NCvk42$AO$JdAo zmSNcAYEyMZ1q^orWuY3GZ$;a=kFqrh9Vn z^55NeZv=6QYA!n)cj#)myXs*SofLg*Ui_{APenmaZO4Juw`S;8*F9y+?Ne_UN1Id@ zXL?TAqREy2Z`o(|_=>_0=4WewZnn;9hIE)$1|#vV<_hQE$MXb8F{|DM($a={XMU2bm_^8z80oT~yN!`bIT3Rx!J%7V;|xLBH{`MatSy#G5ptwQB37 zw88?#xK6GC!kbt7?d?DTCq3(K)_gUQLep89Qn6~+H4MLDJbf+%MY#h)L>#!yrDu(uWN5%T2mc>VA?Nm&G0bJt*@2>nHM zqbmL2VY%PbSJQVD&5nJ$QT)}Jk;V%WsFLPcwRc)t_L9UdD8ueWipGt*x^F6xrY&I$ z)?x9JzoSbhVi?4njSL|7Mci*9=D6&3B}7rxQ`6Wuc1b3?!v;%=KiB%J;S(?Hw9&;Y zOI43Ch`G2V_%YHwQqig64Qi?qe9VQ~0-DyiCkwrnFD%?b<$pDa*IF56U4+bSUF4e6FmOMyh6eFzb>|(fOg|bwXl7A< z@{|~`=SbdcaTtj4*jmIp8oJ{}q>?po+>pJsEYZ4^A)dz~ih$IThu=vTZINnsg8fN4 zu3s^AU()BYN|bFTps$&TuhD-+@@1Xec>fudT6$fH)c;?v@k4CN_Do^DbAxpkkd1iDSd>>(wy%?iQx3LsVXT#012*~eSIC0`NbtZZ*1 zMqAumz(N5E(0b~4=XU7I$dwhK7?b!X7U8o7kYCNlJU6x^^%2VSow?=aOr>4$tCc|O z%i7xwlx_K`f0mJ+4x1xr?`)ud=)@z&D`1|GUnB?2e)o#Y6i+AG1jL>iK#?rZzAs0m zIza=f49VP)7CYcFp7yb`_z|FK71=@NX29ZguT~&8MlxCp>g5J5f397<1rB6do;mlu zy%f-w6XTofmg7(Z%(wa5pmSpPG$c#rHm81*^iE8xyPw#v__0!M{k)2g zNFs1k^;(>mxs>3^qbSlUJ~lLBxe)cy*nvaHr<;CHSc;YZFKRyTA#UAFWhhEA=l;(0 z9e~bS+;i_a;^SE}sPv&_$)AlEMzQt-=sl*rtjkU?YT^|s(O|v3VvSr`C;>kxyb=*MsSA~hzq?@b0XDV+-t5DVw#3mf53qD|v;y(g11?a?zfDP?!+FpbSD1xm3zMuj z^D*spfv~7-iLq~uXsEjb_)*J=+{;TA9_CP~^T$@1f9!TXrr&XO6?5P+wX&(GZiHs; z1!ETqfLtG~YR6!C4MlHbTff7gMoNCD$TN1Jd*okj$>+ekGyRN-p3=Q3OzQs%xG>=AVprNr~aAdo7ZD* zZ{Q&pICN++qDD1cQ9=FzcPFTdMoojn;K}pmEbo|^VR7s9BTSo8lwy0b8OXht9fCxP zRcZ>$62{R7xtr{u9fv#UyDU69{9zUj{G#4Ab66Q96j_F2BA9pT97v)lip7&gXh8v_ za_)+HM#yZp#MGIPs#?#60bP}J4D;S0>R~p9`TFa~INL-#dx-mWaX+ZwMb6qO>J3nc z9N^~|ivO8KzcN!sE#hRNPack35I~(-5xg!lhkn)9^J1+RL)y+YA<7`xH0!sfE)D&) z1CQ4v!i82MC%e^dM0RMA=E!4_e@1eJ9XMxXQA9Fc1maJ+vR!7~9~RExjQ7Hv=g$Ox zO0?li;#}h@8b#Ph==|}so^(cQO8aaTIGOd7@+|CVw`2JV6lNn#+Wps-VS$79a2h`h zc^b+JuRxc*YneT6te7mGEI-SgZ$qTjroK5+-m3eDE7=lrzWOk@OPFVNy4yrw->fq zUGLAWLJ`;tV|6Po>W{uVo2<$QZWna%it~MwlzL&}579ZZC87PCPOL7HwnK2R^jA)d z^w(v=5UNtDhg*$~O8UcjVK`Bm#}uT9tnv5AMt{{BSKPsD^qC80v>~ycbJ^-@U82S_ za5Gs;P~-T3O^*(aq4JM&>Uk-ilu^gactx?w+_4zjh9lh)1^X!df;6QGf&32PU{l|@ zGtUA=DT|!3v!gq|@4fNs!tL4jRqD?rNK19YE-joe-v-qV&N0ZOtu1-jX?;RJa5T<*#Php?@{_&{w95++s)Z_8mTPjdlM*!<(i_p3s(XTKGWy9=S~*{d9T8uu*X#owB#F1K93EyO3j3PJK()w7RSH}RN zo3Z5;6WH)nh9h7M-;vHd?M?pjX*81J;$=*1J^?W?B%j`RM`ULtE%NTJl|L|YE}%X+ zfgK-4dGql~Xs=I?WmO zKR@#GXAV4^Nuz2*kK;BtX5)f{##M;4dchu;F=CQkpr<&n&T)5d!UNk|1d37PRb?P7 z1_y7l?<7E?K2vVaEv=+W$kIzB%oqbZ`&;X-QX~B~FIKpdVf7;m@Uv=cl4u(q{4o+q zI?(V2-tWYSbGgG*asJO|c$t)|tC*kR5JUf<)(R3`G3a`i zKb+;_&(_b@ZY5#?^WlBLoL@@c+bmE*Nx<*HWpXQP(5Z3}3*w3#a8nvPF87@P{%!ti z(a#0>!Ac~7Lc4SFPGRK3L^ z;5MP*nzuCGi=oX+Kog^iowrdiXVIyvjufwoId=_9DrTwwkN}y7^HvqCBK9SSzqLjD z-wu(sR3h-+nIk^1XlR(Q1M@&&*B>lII)9*f{%J-utTV$6OuaCgOQ96mMsVR5Vs_wV zikqbzEnezA@LpDsI0C@&;Rw2Qk&je8mh^@QXn1|G?vkHO`G49Eapd=h-hA7&f(C1S zN+kCH!P}aUH~39LgQGo?JrA-d+wTHzdy_sqprpzlz+TM*-hCt7cqFF=HQf&x^0)c^ z9fXkN2_cYCP1x})Px7x8)V<$IH1Q$;H@~kxAO(uT*AqiAz``^O3g5YH))Pew)4n>Z z0YJ*!71N~4I8x)4=x{0^sb4TnO56aha^Nen2F2*t;v>J&xb3J1N2!zP?Ee;<1*Wu(mjmgd1W&%)+)V<2DTlmg^`uZfV{>HKE7HijO@%s(H0Tn z6DMOM-VD2-FK%I!>Rj(70tVNXKx~Xv0JKRu@VvhQjAep78))EHpg!?(&lnfxRB-9$ zi4{-^SHuU81q>Ta*$=IIc&q3SNYWU3*|=hA(UJL=IDB=zHKRV@ow?Ad zJl`xq)myLXUL*&?KFM02JR`W*LVK#RJ4Vunz{NOAkC7pf9D->T8 zQMaz`(EyqsKEP%8+UsZX&w99QJxn7U29$`s5f(>7l}R)gUNf3IPt+%y}-tH zZgykjBk~mDx#S1a!mx1t=INK4sVwlZRe$N^o`f^OcHhVMB?G=}9na~BdNQZi66~(t z*E>0h2iH!VotrflArtlU)u|t!e*rduSVj$BWZjjh*F<1Yx_hO#PMw@6Qecp#g}J^a zp&SMl2k>C!C_3cc*o%vLq{PDDgEUuh`?{)exAJ3~=J68`M#gPgqu2A2N`0OOkJj&q zT65Ii6*MePTDa9T5rGTifNKxDSW^Y@Z#s+dA|Fi@FwPqF>f`@5>aS95v~TIjLli%g zEz>38n&s@BWV`_TlQds*aU+UuPsPISRD?TngLb9^vF-6Cg5$h6_f$RpiP8IJfj*CuvgE|fhxN;=y&i|RXk4% zSn55?aBgf0`_2Ap22iVoA7lE70RN?K&ANw=-0GazWODg8yY8xi(|d7HMg`Amf<^M| zK{_8%3uOiPOUc-pS_fr|TlhsNpDbj}2*aBEO%D zHJ=pqi!=Xuy3XKrpaoF!!FVhQGQ3>y4O4%p#D+i{O|w{a^*P$@xY55ouwzKKXy6|t~|2O3v@}2@;Q2V?0*eTx^^&g z=0wD0IpC>@iU|65u6gtQnyZiL7Ai*C4q8Xvb)#v#0hk9YK|s=)RgKsaD<#y&EeB!` zpw68@BRfd%n*1zxAp7+0Siz3%LVfEB0IP)(&wU5rS0|n4{=0(E6LZ#WEv5##`7#<+ z%^=BjK3crFUb~&zrWkC;Lzy&AI_DXVWRn2@*+6?2lzskMf}-pDYWvgj24F5yztoEM zaAr9#EyHtH>lF~x@k-TPdcs*o1U#J(Uwdhn>9VkH=q8m7>bN+TPTq6H%@|~WoC#MU zhvZcGK1pW=X1K`EO)?u;gl;9rZ386HC-j<2yUZf@J*;|rwL&04}! zOt)=bcI&=|^WT?myvu^3RdNvK?djm40V2T=u0tN!+jAl0eCNEX{()4Ht~w$+%!?Q_Jof zX91XRpZ@i^)80VZba~@Z_nPOpH`C>d$;y}+NLvf<{q5rbD53%{tJnoi5ioL#P`Ego zpMT%d^DD<8LlicA1MT6UNtf`~F4!TNl#*wk*I!o8UldWGq&d#%4yS0r7Sz=Rh5RfY zN9KCXDx1~(kwA*(l>N=)VI82%w-9tkj{&Vz@y+IWZ;`o${MuOJ;fq~)%-Pmw#gOwC zljX~;`iTOK=;NCd6PMsi<5TMoH1BqERQ|ED+=sPbE6%!GPRBY&$pMx0bxWw;3mRfK zDLssedtisSrquzGLsZk`Nc}Q@*XVVpMxL&ojl3p@1(*F*8xpN6Hy4nH#3N&iQ@&$; zf}8oRWNRSu&=%gRJ&pRT_hV`HJHcL~Xb7y;CKs<0?-3zK4NlSrvE63tsZWB4SNP~D#}nnRe0@~Y9!ugF z{>G^yH_gC22s{}7Pq7`l!0Rcc|&c1W=Y zF@1mnY@v?FaRho+g5yD@9$h?RLFGak6fe)l9x)Ewe11Ks>QfOO^N zIrcHJG@zIXK|t|C)YcNZ3j7iIc>et)j&zs$JQL~89&DAsK!9AnU+GV*0_e@*f4m#% zTk+U&`g2j>&-DO-6_L~n6rB}oR#ojn1>wxx+<>f+IZp@x(LD>}ZdLmm0AV#@9}WOX z7pF3+ojHo8Bf1yJNd zLmXogDf>yUGPtRP z2sOjD=D)VBc1Yo9*}~#Ux&IB2ujH~(X0G)AWYKo|dc`%oaKYx1-0oGf?L7ZS_OxGJ zx3TyCTNnrLRk7`g#K1jUXbT?zCDY%s+m*qd86HF|x&5uB&8VdP+q#rdfE3+-#W%oT z40x7;v5p}`C%X^TSFr7n8auDwXh6!Dtc>}b3ChdM_hvN8L4an6$kFr+#sAzeirm-N zH$CczEH!~*&ly5aK;{WEcQY%HkKeauC(*lzv>4}`*0jSo3Poq@1E9t6^v^c|U^deG z&pGvQ?8C663?)Y9Bh)ot1C};IeBvNa)l3V=c3K~_AWFM0y>S~-ORV3yKJ+$C1(uYJ zg21!=>H$@bFH13`_q%^{XoK5j5#>J~@+Z(~?-@H(Qo5)V3Ic5U!}+TF7jdKz7a38c z?5d_dUD4FVT6VV+rKrvz8y(6heckG+6#%^~LGOZqHHCN zxjE&45)Y3b$d)spcNrXPb@mbtHZKJo2$*f4vt5I;p>r3|5##?gKE*e5=1K}mV|`%jCY zqX1~=?&VBQmY(nP%_&8|a(&H4$UeDtB4k$bV3XgER{gfVm)ibB1>SlYDNm;USy#PL zn0=6IpzGtu)Nf*HhzxDt4EjdYhSYO2wNfId#qMySKxyrnzXw{Kbf03A_PapJXO)>F zv5+BvdDEFxwI{`|eL59DI{|vXh`5&avr!xu`4zmVpw0+7=|53Lzzv9vHg^+%o?q*4 zZRJ1_;28p6O2$|qI{C`UEQ+d_J9map=H4Rue+od8kzeV`VsiB6Z#dxK{bmgOI5z-W z<-ROaPaOK$FHTZLbQ16~SywoQ*?DL+)BSjvtpL0hT?_g~6$(@a=dND8s&%vAJfhiF zQmk;DS>lb$h%P0sqwHfJk^pH-W!Gxg^C=9eXDbhQE9$UmRb&eMxZVnoiD=4; zy;YT!Gf7-?4LJIJEB}H$LO{(Z{pHQ0-bb)9p1hETnH+2u31HW6mTTKzt?z_geSLkk zAL|MM`du8GsNKd9?3GC{2eu*SY4!$Zv7kHD>CMY#|q`E|+jk=!zL`{>yYc4eD zRr35Zi&q1Q!w9teSQ6ulX8btkW+hkIwz+JtoM=pwbMF&exKJarWuyv!ggBQRo3nukFhMhN6YfI)8fs@tM zc))@g7fV_=^`<=IriHcjY<$7E{`Yhlz+aQhzv3j6dIv)@$`%4}xgyjS-} zV>B~RK?$U3^uy?fQb8vrckwfJ++Ms0a4>p61Tck~SUU_wY3NU%KG{Fp_&3xjDUD&A zbzX2gb!xb*Yj5Nd0%^P6W(fAaq~N)2prG6D$`7Bw>A17$`jiM=SwJp)&@($Gzv3pM z6Uz&~NakwOjyVV5$qEE}*N0L-IMN?+?L8p?$kBZ#N2UyvKrT%R&`{-T`bJoWs4W_# z-nMJ?DgmK45H?iL_t`y4V5fO0r0@Yh_@TGOkq3knz8DF#1&7{jH3U~OXssUKBHopb z1R~hM(;|_&DLl>l2q@%mB~KJ!?TB0+KzZ}js?2d`Vj%uA;yip6_EWm{8y^g@v`C8s zl-I1&Zd*$s&~$%UT7FJj5P>TOI|Fac_6-397#FRshhPu{SZ0?w$+YScAoH*o+cA{& za5vozgzlM``K+;#Pn+KaMSd1Qv%fpy*LFc9w?%H?O1X*W+JsaRP;iQUawi)LA&4)M zJW>?ZoNM*`rYFBr#iNEnpqaabLHQA;NdN&z^2GxR7KH#GP;h(_52%-!b3a1>ikNr= zC8~yF@nIq?Zel>}Zeeiqjc!CL0?#b!jx_o`4N|a?^TI1O^2n3*P2m@$fTmXJ@uv0l zd?1I+M=tpYC2bm_(C7+twXKQ0cL6O#e%pZiw|`k4ltdt4l7YX7kojM2;La5;MR|+g zna{t3fL~E|kS0_XJ`jTe_W2IrnYtnMOLLB}{rZ1!*Z%XVNh+wv^3$Wl59ja={{d)K zqV(mrh1yW}z~P$Xd0MjyxV0b%!P=e?fdtr@{LQAVU@tv6b)~Q0KrTWj8Us29llVJ$ z??JY*rzSYAJZnLl5Z|6!OHvoOc`3|#q&W?@Hiz`koT@2w&S>to8P8eF+daB9#bqqH ze~aHYp}(s7&We6wl*e^-9Fy-&r!)Z$NGUwp;%z5m$ri<>$th<*ReocUdxkls00ktSI9?fmu#~RJ&Yyz9|=Nwh$2MCpG|gfG3A#piZnuoYK0R5RhJfinWQXVnjP7#A0KT-__T2qbLw6of5+*y o`q83FzZK{72#buJ^bsnWQ(^3>c+w>a$u)tQvDJ}EBTCf&0e?hD+5i9m literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index a8fb6d5..a907668 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -16,7 +16,6 @@ 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.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon @@ -42,15 +41,11 @@ 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.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextAlign 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_avatar import coop.composeapp.generated.resources.ic_send import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.UnsignedEvent @@ -58,6 +53,7 @@ import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.humanReadable import su.reya.coop.roomId +import su.reya.coop.shared.Avatar import su.reya.coop.shared.displayNameFlow import su.reya.coop.shared.pictureFlow @@ -113,21 +109,11 @@ fun ChatScreen( title = { Row(verticalAlignment = Alignment.CenterVertically) { Box { - if (!picture.isNullOrBlank()) { - AsyncImage( - model = picture, - contentDescription = "Room Avatar", - modifier = Modifier - .size(32.dp) - .clip(CircleShape), - contentScale = ContentScale.Crop - ) - } else { - Icon( - painter = painterResource(Res.drawable.ic_avatar), - contentDescription = "User" - ) - } + Avatar( + picture = picture, + description = displayName, + size = 32.dp, + ) } Spacer(modifier = Modifier.size(8.dp)) Text( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 05a71cb..da90657 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -13,7 +13,6 @@ 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.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -44,13 +43,10 @@ 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.layout.ContentScale import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.toClipEntry import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import coop.composeapp.generated.resources.Res -import coop.composeapp.generated.resources.ic_avatar import coop.composeapp.generated.resources.ic_search import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource @@ -58,6 +54,7 @@ import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Room 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 @@ -103,21 +100,11 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) { } // User IconButton(onClick = { showBottomSheet = true }) { - if (userProfile?.asRecord()?.picture != null) { - AsyncImage( - model = userProfile?.asRecord()?.picture, - contentDescription = "User Avatar", - modifier = Modifier - .size(32.dp) - .clip(CircleShape), - contentScale = ContentScale.Crop - ) - } else { - Icon( - painter = painterResource(Res.drawable.ic_avatar), - contentDescription = "User" - ) - } + Avatar( + picture = userProfile?.asRecord()?.picture, + description = userProfile?.asRecord()?.displayName, + size = 32.dp, + ) } } ) @@ -188,19 +175,11 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) { .clip(MaterialShapes.Cookie9Sided.toShape()), contentAlignment = Alignment.Center ) { - if (userProfile?.asRecord()?.picture != null) { - AsyncImage( - model = userProfile?.asRecord()?.picture, - contentDescription = "User Avatar", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } else { - Icon( - painter = painterResource(Res.drawable.ic_avatar), - contentDescription = "User" - ) - } + Avatar( + picture = userProfile?.asRecord()?.picture, + description = userProfile?.asRecord()?.displayName, + shape = MaterialShapes.Cookie9Sided.toShape(), + ) } Spacer(modifier = Modifier.size(8.dp)) Box( @@ -250,21 +229,7 @@ fun ChatRoom(room: Room, onClick: () -> Unit) { ListItem( modifier = Modifier.clickable(onClick = onClick), leadingContent = { - if (!picture.isNullOrBlank()) { - AsyncImage( - model = picture, - contentDescription = "Room Avatar", - modifier = Modifier - .size(48.dp) - .clip(CircleShape), - contentScale = ContentScale.Crop, - ) - } else { - Icon( - painter = painterResource(Res.drawable.ic_avatar), - contentDescription = "User" - ) - } + Avatar(picture = picture, description = displayName) }, headlineContent = { Row( 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 + ) +} -- 2.49.1 From d56847f5d4119ea781dcd7a878531678daf7e3c7 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 15 May 2026 09:41:50 +0700 Subject: [PATCH 27/43] optimistic update message on send --- .../kotlin/su/reya/coop/screens/ChatScreen.kt | 43 +++++++++++++------ .../kotlin/su/reya/coop/screens/HomeScreen.kt | 1 + .../commonMain/kotlin/su/reya/coop/Nostr.kt | 37 ++++++++++------ .../kotlin/su/reya/coop/NostrViewModel.kt | 7 ++- .../commonMain/kotlin/su/reya/coop/Room.kt | 21 +++++++++ 5 files changed, 84 insertions(+), 25 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index a907668..2db5b00 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -42,7 +42,6 @@ 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.text.style.TextAlign import androidx.compose.ui.unit.dp import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back @@ -51,7 +50,7 @@ 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.humanReadable +import su.reya.coop.formatAsGroupHeader import su.reya.coop.roomId import su.reya.coop.shared.Avatar import su.reya.coop.shared.displayNameFlow @@ -73,7 +72,10 @@ fun ChatScreen( 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 } @@ -96,7 +98,9 @@ fun ChatScreen( // Handle new messages viewModel.newEvents.collect { event -> if (event.roomId() == id) { - messages.add(0, event) + if (event.id() !in messages.map { it.id() }) { + messages.add(0, event) + } } } } @@ -163,8 +167,13 @@ fun ChatScreen( contentPadding = PaddingValues(16.dp), reverseLayout = true ) { - items(messages.toList(), key = { it.id()?.toBech32()!! }) { event -> - ChatMessage(event) + groupedMessages.forEach { (dateHeader, messagesInGroup) -> + items(messagesInGroup, key = { it.id()?.toBech32()!! }) { event -> + ChatMessage(event) + } + item { + DateSeparator(dateHeader) + } } } ChatInput( @@ -182,6 +191,22 @@ fun ChatScreen( ) } +@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 @@ -211,12 +236,6 @@ fun ChatMessage( Column( horizontalAlignment = if (isMine) Alignment.End else Alignment.Start ) { - Text( - text = rumor.createdAt().humanReadable(), - style = MaterialTheme.typography.labelSmall, - textAlign = if (isMine) TextAlign.End else TextAlign.Start, - ) - Spacer(modifier = Modifier.size(4.dp)) Surface( color = containerColor, contentColor = contentColor, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index da90657..4f0f03d 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -179,6 +179,7 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) { picture = userProfile?.asRecord()?.picture, description = userProfile?.asRecord()?.displayName, shape = MaterialShapes.Cookie9Sided.toShape(), + modifier = Modifier.fillMaxSize() ) } Spacer(modifier = Modifier.size(8.dp)) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 107d4cb..9607d23 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -42,8 +42,8 @@ import rust.nostr.sdk.TagKind import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnwrappedGift +import rust.nostr.sdk.giftWrapAsync import rust.nostr.sdk.initLogger -import rust.nostr.sdk.makePrivateMsgAsync import rust.nostr.sdk.nip17ExtractRelayList import kotlin.time.Duration @@ -551,7 +551,8 @@ class Nostr { to: List, content: String, subject: String? = null, - replies: List = emptyList() + replies: List = emptyList(), + onNewMessage: ((UnsignedEvent) -> Unit)? = null ) { try { val currentUser = @@ -578,20 +579,32 @@ class Nostr { } } - for (receiver in to.plus(currentUser)) { - // Construct the gift wrap event - val event = makePrivateMsgAsync( - signer = signer, - receiver = receiver, - message = content, - rumorExtraTags = tags - ) + 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() - println("Sending message to: ${receiver.toBech32()}") + // Emit the rumor to the chat screen + if (receiver == currentUser) { + onNewMessage?.invoke(rumor) + } + + // Construct the gift wrap event + val gift = giftWrapAsync( + signer = signer, + receiverPubkey = receiver, + rumor = rumor, + extraTags = tags + ) // Send the event to receiver's NIP-17 relays client?.sendEvent( - event = event, + event = gift, target = SendEventTarget.toNip17(), ackPolicy = AckPolicy.none(), authenticationTimeout = Duration.parse("2s") diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index d55ad51..c5df147 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -333,7 +333,12 @@ class NostrViewModel( to = room.members.toList(), content = message, subject = room.subject, - replies = replies + replies = replies, + onNewMessage = { event -> + viewModelScope.launch { + _newEvents.emit(event) + } + } ) } catch (e: Exception) { showError("Error: ${e.message}") diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index 22b65bc..c0260e1 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -125,6 +125,27 @@ fun Timestamp.ago(): String { } } +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()) -- 2.49.1 From 6b448a56f844deca7882b766e8768a94e9107bb1 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 15 May 2026 17:09:59 +0700 Subject: [PATCH 28/43] new chat screen --- .../composeResources/drawable/ic_close.xml | 10 + .../drawable/ic_close_small.xml | 10 + .../composeResources/drawable/ic_new_chat.xml | 10 + .../androidMain/kotlin/su/reya/coop/App.kt | 39 ++- .../kotlin/su/reya/coop/Navigation.kt | 6 + .../kotlin/su/reya/coop/screens/HomeScreen.kt | 51 ++- .../su/reya/coop/screens/NewChatScreen.kt | 300 ++++++++++++++++++ .../kotlin/su/reya/coop/screens/ScanScreen.kt | 49 +++ .../commonMain/kotlin/su/reya/coop/Nostr.kt | 80 ++++- .../kotlin/su/reya/coop/NostrViewModel.kt | 45 ++- .../commonMain/kotlin/su/reya/coop/Room.kt | 2 +- 11 files changed, 571 insertions(+), 31 deletions(-) create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_close.xml create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_close_small.xml create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_new_chat.xml create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_close.xml b/composeApp/src/androidMain/composeResources/drawable/ic_close.xml new file mode 100644 index 0000000..7a0ff35 --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_close.xml @@ -0,0 +1,10 @@ + + + diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_close_small.xml b/composeApp/src/androidMain/composeResources/drawable/ic_close_small.xml new file mode 100644 index 0000000..1b7d195 --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_close_small.xml @@ -0,0 +1,10 @@ + + + diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_new_chat.xml b/composeApp/src/androidMain/composeResources/drawable/ic_new_chat.xml new file mode 100644 index 0000000..3d23f4c --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_new_chat.xml @@ -0,0 +1,10 @@ + + + diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 82ec4e0..3e826e8 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -14,10 +14,10 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -26,8 +26,10 @@ import su.reya.coop.coop.storage.SecretStore import su.reya.coop.screens.ChatScreen import su.reya.coop.screens.HomeScreen import su.reya.coop.screens.ImportScreen +import su.reya.coop.screens.NewChatScreen import su.reya.coop.screens.NewIdentityScreen import su.reya.coop.screens.OnboardingScreen +import su.reya.coop.screens.ScanScreen val LocalNostrViewModel = staticCompositionLocalOf { error("No NostrViewModel provided") @@ -37,18 +39,26 @@ val LocalSnackbarHostState = staticCompositionLocalOf { error("No SnackbarHostState provided") } +val LocalNavController = staticCompositionLocalOf { + error("No NavController provided") +} + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun App(dbPath: String) { val context = LocalContext.current + val navController = rememberNavController() + val darkMode = isSystemInDarkTheme() + + // Snackbar + val snackbarHostState = remember { SnackbarHostState() } // Initialize Nostr and SecretStore val nostr = remember { Nostr() } val secretStore = remember { SecretStore(context) } val viewModel: NostrViewModel = viewModel { NostrViewModel(nostr, secretStore) } - - // Dynamic color scheme - val darkMode = isSystemInDarkTheme() + + // Enabled the dynamic color scheme val colorScheme = when { android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> { if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) @@ -58,13 +68,12 @@ fun App(dbPath: String) { else -> expressiveLightColorScheme() } - // Snackbar - val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(Unit) { viewModel.initAndConnect(dbPath) viewModel.startNotificationHandler() viewModel.getChatRooms() + + // Collect error events from the ViewModel viewModel.errorEvents.collect { message -> snackbarHostState.showSnackbar(message) } @@ -76,9 +85,8 @@ fun App(dbPath: String) { CompositionLocalProvider( LocalNostrViewModel provides viewModel, LocalSnackbarHostState provides snackbarHostState, + LocalNavController provides navController, ) { - rememberCoroutineScope() - val navController = rememberNavController() val emptySecret by viewModel.emptySecret.collectAsState(initial = null) LaunchedEffect(emptySecret) { @@ -136,7 +144,8 @@ fun App(dbPath: String) { } composable { backStackEntry -> HomeScreen( - onOpenChat = { id -> navController.navigate(Screen.Chat(id)) } + onOpenChat = { id -> navController.navigate(Screen.Chat(id)) }, + onNewChat = { navController.navigate(Screen.NewChat) } ) } composable { backStackEntry -> @@ -146,6 +155,16 @@ fun App(dbPath: String) { onBack = { navController.popBackStack() }, ) } + composable { backStackEntry -> + NewChatScreen( + onBack = { navController.popBackStack() }, + ) + } + composable { backStackEntry -> + ScanScreen( + onBack = { navController.popBackStack() }, + ) + } } } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt index 521babf..676eb7d 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt @@ -9,6 +9,9 @@ sealed interface Screen { @Serializable data class Chat(val id: Long) : Screen + @Serializable + data object NewChat : Screen + @Serializable data object Onboarding : Screen @@ -17,4 +20,7 @@ sealed interface Screen { @Serializable data object NewIdentity : Screen + + @Serializable + data object Scan : Screen } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 4f0f03d..1dfed82 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -13,9 +13,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem @@ -24,17 +26,23 @@ import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedListItem import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberTooltipState import androidx.compose.material3.toShape import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -47,6 +55,8 @@ import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.toClipEntry import androidx.compose.ui.unit.dp import coop.composeapp.generated.resources.Res +import coop.composeapp.generated.resources.ic_new_chat +import coop.composeapp.generated.resources.ic_scanner import coop.composeapp.generated.resources.ic_search import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource @@ -61,7 +71,10 @@ import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable -fun HomeScreen(onOpenChat: (Long) -> Unit) { +fun HomeScreen( + onOpenChat: (Long) -> Unit, + onNewChat: () -> Unit, +) { val clipboard = LocalClipboard.current val snackbarHostState = LocalSnackbarHostState.current val viewModel = LocalNostrViewModel.current @@ -74,6 +87,8 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) { val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList()) val sheetState = rememberModalBottomSheetState() + val listState = rememberLazyListState() + val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } var showBottomSheet by remember { mutableStateOf(false) } Scaffold( @@ -98,6 +113,13 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) { contentDescription = "Search" ) } + // QR Scanner + IconButton(onClick = { /* TODO: Open search */ }) { + Icon( + painter = painterResource(Res.drawable.ic_scanner), + contentDescription = "Scanner" + ) + } // User IconButton(onClick = { showBottomSheet = true }) { Avatar( @@ -109,6 +131,32 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) { } ) }, + floatingActionButton = { + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Above, + spacingBetweenTooltipAndAnchor = 8.dp, + ), + tooltip = { + if (!expandedFab) { + PlainTooltip { Text("New Chat") } + } + }, + state = rememberTooltipState(), + ) { + ExtendedFloatingActionButton( + onClick = onNewChat, + expanded = expandedFab, + icon = { + Icon( + painter = painterResource(Res.drawable.ic_new_chat), + contentDescription = "New Chat" + ) + }, + text = { Text("New Chat") }, + ) + } + }, content = { innerPadding -> Surface( modifier = Modifier @@ -137,6 +185,7 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) { } } else { LazyColumn( + state = listState, modifier = Modifier.fillMaxSize() ) { items(chatRooms.toList(), key = { it.id }) { room -> diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt new file mode 100644 index 0000000..8e2ed1a --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt @@ -0,0 +1,300 @@ +package su.reya.coop.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.dp +import coop.composeapp.generated.resources.Res +import coop.composeapp.generated.resources.ic_arrow_back +import coop.composeapp.generated.resources.ic_close_small +import coop.composeapp.generated.resources.ic_scanner +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.painterResource +import rust.nostr.sdk.PublicKey +import su.reya.coop.LocalNavController +import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.Screen +import su.reya.coop.shared.Avatar +import su.reya.coop.short + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun NewChatScreen( + onBack: () -> Unit, +) { + val snackbarHostState = LocalSnackbarHostState.current + val navController = LocalNavController.current + val viewModel = LocalNostrViewModel.current + + val selectedReceivers = remember { mutableStateListOf() } + var query by remember { mutableStateOf("") } + + val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle + val qrResult by savedStateHandle?.getStateFlow("qr_result", null)?.collectAsState() + ?: remember { mutableStateOf(null) } + + LaunchedEffect(query) { + if (query.length >= 3) { + delay(500) // 500ms debounce + // TODO: Implement search + } + } + + LaunchedEffect(qrResult) { + qrResult?.let { + println("QR result: $it") + navController.currentBackStackEntry?.savedStateHandle?.remove("qr_result") + } + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text("New Chat") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(Res.drawable.ic_arrow_back), + contentDescription = "Back" + ) + } + }, + actions = { + IconButton(onClick = { navController.navigate(Screen.Scan) }) { + Icon( + painter = painterResource(Res.drawable.ic_scanner), + contentDescription = "Scanner" + ) + } + } + ) + }, + content = { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surface, + ) { + FlowRow( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "To:", + modifier = Modifier.align(Alignment.Top), + style = MaterialTheme.typography.labelMediumEmphasized, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + selectedReceivers.forEach { receiver -> + ReceiverChip( + pubkey = receiver, + onRemove = { selectedReceivers.remove(receiver) } + ) + } + BasicTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier + .widthIn(min = 50.dp) + .align(Alignment.CenterVertically), + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + if (query.isEmpty() && selectedReceivers.isEmpty()) { + Text( + "Type a npub or address", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.5f + ) + ) + } + innerTextField() + } + } + ) + } + } + Spacer(modifier = Modifier.size(16.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + // TODO: add result list + ContactList( + selectedReceivers = selectedReceivers, + onContactClick = { pubkey -> + val roomId = viewModel.createChatRoom(listOf(pubkey)) + navController.navigate(Screen.Chat(roomId)) + } + ) + Spacer(modifier = Modifier.size(16.dp)) + } + } + } + ) +} + +@Composable +fun ReceiverChip( + pubkey: PublicKey, + onRemove: () -> Unit +) { + val viewModel = LocalNostrViewModel.current + val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) } + val metadata by metadataFlow.collectAsState(initial = null) + + val profile = metadata?.asRecord() + val displayName = profile?.name ?: profile?.displayName ?: pubkey.short() + val picture = profile?.picture + + InputChip( + selected = true, + onClick = onRemove, + label = { Text(displayName) }, + avatar = { + Avatar( + picture = picture, + description = displayName, + size = 24.dp + ) + }, + trailingIcon = { + Icon( + painter = painterResource(Res.drawable.ic_close_small), + contentDescription = "Close" + ) + } + ) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ContactList( + selectedReceivers: SnapshotStateList, + onContactClick: (PublicKey) -> Unit +) { + val viewModel = LocalNostrViewModel.current + val contactList by viewModel.contactList.collectAsState(initial = emptySet()) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + Text( + text = "Contacts", + style = MaterialTheme.typography.titleLargeEmphasized, + ) + Spacer(modifier = Modifier.size(8.dp)) + contactList.forEachIndexed { index, item -> + ContactListItem( + pubkey = item, + index = index, + total = contactList.size, + isSelected = selectedReceivers.contains(item), + onClick = { onContactClick(item) }, + onLongClick = { + if (!selectedReceivers.contains(item)) { + selectedReceivers.add(item) + } + } + ) + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ContactListItem( + pubkey: PublicKey, + index: Int, + total: Int = 0, + isSelected: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit +) { + val viewModel = LocalNostrViewModel.current + val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) } + val metadata by metadataFlow.collectAsState(initial = null) + + val profile = metadata?.asRecord() + val displayName = profile?.name ?: profile?.displayName ?: pubkey.short() + val picture = profile?.picture + + SegmentedListItem( + selected = isSelected, + onClick = onClick, + onLongClick = onLongClick, + shapes = ListItemDefaults.segmentedShapes( + index = index, + count = total + ), + leadingContent = { + Avatar( + picture = picture, + description = displayName, + size = 36.dp + ) + }, + supportingContent = { Text(text = pubkey.short()) }, + content = { + Text( + text = displayName, + style = MaterialTheme.typography.titleMediumEmphasized, + ) + } + ) +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt new file mode 100644 index 0000000..041da7e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt @@ -0,0 +1,49 @@ +package su.reya.coop.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import coop.composeapp.generated.resources.Res +import coop.composeapp.generated.resources.ic_arrow_back +import org.jetbrains.compose.resources.painterResource +import su.reya.coop.LocalNavController + +@Composable +fun ScanScreen( + onBack: () -> Unit +) { + val navController = LocalNavController.current + + val onResult: (String) -> Unit = { result -> + navController.previousBackStackEntry + ?.savedStateHandle + ?.set("qr_result", result) + navController.popBackStack() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Scan QR") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(Res.drawable.ic_arrow_back), + contentDescription = "Back" + ) + } + }, + ) + } + ) { innerPadding -> + Box(modifier = Modifier.padding(innerPadding)) { + Text("Scan QR") + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 9607d23..0f3a301 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -1,7 +1,10 @@ package su.reya.coop import io.ktor.client.HttpClient +import io.ktor.client.call.body import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.request.get +import io.ktor.client.statement.HttpResponse import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay @@ -24,6 +27,8 @@ import rust.nostr.sdk.KindStandard import rust.nostr.sdk.LogLevel import rust.nostr.sdk.Metadata import rust.nostr.sdk.MetadataRecord +import rust.nostr.sdk.Nip05Address +import rust.nostr.sdk.Nip05Profile import rust.nostr.sdk.NostrDatabase import rust.nostr.sdk.NostrGossip import rust.nostr.sdk.PublicKey @@ -56,8 +61,6 @@ class Nostr { private set var msgRelayList: Map> = emptyMap() private set - var contactList: List = emptyList() - private set suspend fun init(dbPath: String) { try { @@ -87,6 +90,12 @@ class Nostr { client?.addRelay(RelayUrl.parse("wss://relay.primal.net")) client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) + // Add search relay + client?.addRelay( + url = RelayUrl.parse("wss://antiprimal.net"), + capabilities = RelayCapabilities.read() + ) + // Indexer relay for NIP-65 discovery client?.addRelay( url = RelayUrl.parse("wss://indexer.coracle.social"), @@ -107,7 +116,6 @@ class Nostr { suspend fun exit() { signer.switch(Keys.generate()) deviceSigner = null - contactList = emptyList() } suspend fun setSigner(keys: AsyncNostrSigner) { @@ -184,6 +192,7 @@ class Nostr { suspend fun handleNotifications( onMetadataUpdate: (PublicKey, Metadata) -> Unit, + onContactListUpdate: (List) -> Unit, onNewMessage: (UnsignedEvent) -> Unit, onEose: () -> Unit, ) = coroutineScope { @@ -217,6 +226,12 @@ class Nostr { } } + if (event.kind().asStd()?.equals(KindStandard.CONTACT_LIST) == true) { + if (isSignedByUser(event = event)) { + onContactListUpdate(event.tags().publicKeys()) + } + } + if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) { if (isSignedByUser(event = event)) { getUserMessages(msgRelayList = event) @@ -457,7 +472,7 @@ class Nostr { ) ) - client?.subscribe(target = target, id = "metadata-reqs", closeOn = opts) + client?.subscribe(target = target, closeOn = opts) } catch (e: Exception) { throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e) } @@ -494,13 +509,10 @@ class Nostr { Filter().kind(kind).author(userPubkey).pubkeys(room.members.toList()); // Check if the user is interacting with the room's members - val isInteracting = client?.database()?.query(filter)?.isEmpty() == false; - - // Check if the room's members are in the contact list - val isContact = contactList.containsAll(room.members) + val isOngoing = client?.database()?.query(filter)?.isEmpty() == false; // Set the room kind based on interaction status - if (isInteracting || isContact) { + if (isOngoing) { room.setKind(RoomKind.Ongoing) } @@ -614,4 +626,54 @@ class Nostr { throw IllegalStateException("Failed to send message: ${e.message}", e) } } + + suspend fun profileFromAddress(client: HttpClient, address: Nip05Address): Nip05Profile { + try { + val response: HttpResponse = client.get(address.url()) + val bodyString: String = response.body() + + return Nip05Profile.fromJson(address, bodyString) + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e) + } + } + + suspend fun searchByAddress(query: String): List { + try { + val address = Nip05Address.parse(query) + val profile = profileFromAddress(HttpClient(), address) + + return listOf(profile.publicKey()) + } catch (e: Exception) { + throw IllegalStateException("Failed to search address: ${e.message}", e) + } + } + + suspend fun searchByNostr(query: String) { + try { + val kinds = listOf(Kind.fromStd(KindStandard.METADATA)) + val filter = Filter().kinds(kinds).search(query).limit(10u) + val target = + ReqTarget.manual(mapOf(RelayUrl.parse("wss://antiprimal.net") to listOf(filter))) + + val stream = client?.streamEvents( + target = target, + id = "search", + timeout = Duration.parse("4s"), + policy = ReqExitPolicy.ExitOnEose + ) + + // Collect the results + val results = mutableListOf() + + // Keep searching until the stream is closed or timeout + stream?.next()?.let { event -> + if (event.event != null) { + results.add(event.event!!.author()) + } + } + } catch (e: Exception) { + throw IllegalStateException("Failed to search nostr: ${e.message}", e) + } + } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index c5df147..861eb83 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -17,12 +17,14 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json +import rust.nostr.sdk.EventBuilder import rust.nostr.sdk.EventId import rust.nostr.sdk.Keys import rust.nostr.sdk.Metadata import rust.nostr.sdk.NostrConnect import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.Tag import rust.nostr.sdk.UnsignedEvent import su.reya.coop.blossom.BlossomClient import su.reya.coop.storage.SecretStorage @@ -42,6 +44,9 @@ class NostrViewModel( private val _chatRooms = MutableStateFlow>(emptySet()) val chatRooms = _chatRooms.asStateFlow() + private val _contactList = MutableStateFlow>(emptySet()) + val contactList = _contactList.asStateFlow() + private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) val newEvents = _newEvents.asSharedFlow() @@ -56,6 +61,16 @@ class NostrViewModel( startMetadataBatchProcessor() } + override fun onCleared() { + super.onCleared() + // Ensure all relays are disconnect + viewModelScope.launch { + withContext(NonCancellable) { + nostr.disconnect() + } + } + } + private fun showError(message: String) { viewModelScope.launch { _errorEvents.send(message) @@ -133,6 +148,9 @@ class NostrViewModel( onMetadataUpdate = { pubkey, metadata -> updateMetadata(pubkey, metadata) }, + onContactListUpdate = { contactList -> + _contactList.value = contactList.toSet() + }, onEose = { getChatRooms() }, @@ -287,6 +305,23 @@ class NostrViewModel( } } + fun createChatRoom(to: List): Long { + if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in") + if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required") + + // Construct the rumor event + val rumor = EventBuilder + .privateMsgRumor(to.first(), "") + .tags(to.map { Tag.publicKey(it) }) + .build(nostr.signer.currentUser!!) + + // Create a room from the rumor event + val room = Room.new(rumor, nostr.signer.currentUser!!) + _chatRooms.value += room + + return room.id + } + fun getChatRoom(id: Long): Room { return chatRooms.value.firstOrNull { it.id == id } ?: throw IllegalArgumentException("Room not found") @@ -345,16 +380,6 @@ class NostrViewModel( } } } - - override fun onCleared() { - super.onCleared() - // Ensure all relays are disconnect - viewModelScope.launch { - withContext(NonCancellable) { - nostr.disconnect() - } - } - } } fun PublicKey.short(): String { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index c0260e1..72f8e98 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -111,7 +111,7 @@ fun Timestamp.ago(): String { val duration = now - inputInstant return when { - duration.inWholeSeconds < SECONDS_IN_MINUTE -> "now" + duration.inWholeSeconds < SECONDS_IN_MINUTE -> "Now" duration.inWholeMinutes < MINUTES_IN_HOUR -> "${duration.inWholeMinutes}m" duration.inWholeHours < HOURS_IN_DAY -> "${duration.inWholeHours}h" duration.inWholeDays < DAYS_IN_MONTH -> "${duration.inWholeDays}d" -- 2.49.1 From 5b440112f1c9068231efd96daf74034da3f16388 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 16 May 2026 10:48:44 +0700 Subject: [PATCH 29/43] update new chat screen --- .../drawable/ic_arrow_next.xml | 10 + .../composeResources/drawable/ic_close.xml | 10 +- .../drawable/ic_close_small.xml | 9 +- .../su/reya/coop/screens/NewChatScreen.kt | 254 ++++++++++++------ .../commonMain/kotlin/su/reya/coop/Nostr.kt | 9 +- .../kotlin/su/reya/coop/NostrViewModel.kt | 18 ++ 6 files changed, 220 insertions(+), 90 deletions(-) create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_arrow_next.xml diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_arrow_next.xml b/composeApp/src/androidMain/composeResources/drawable/ic_arrow_next.xml new file mode 100644 index 0000000..b04ebdc --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_arrow_next.xml @@ -0,0 +1,10 @@ + + + diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_close.xml b/composeApp/src/androidMain/composeResources/drawable/ic_close.xml index 7a0ff35..d970f0f 100644 --- a/composeApp/src/androidMain/composeResources/drawable/ic_close.xml +++ b/composeApp/src/androidMain/composeResources/drawable/ic_close.xml @@ -1,10 +1,10 @@ - + android:viewportHeight="960"> + diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_close_small.xml b/composeApp/src/androidMain/composeResources/drawable/ic_close_small.xml index 1b7d195..640b590 100644 --- a/composeApp/src/androidMain/composeResources/drawable/ic_close_small.xml +++ b/composeApp/src/androidMain/composeResources/drawable/ic_close_small.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="960" - android:viewportHeight="960" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="960"> + diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt index 8e2ed1a..bdf3307 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt @@ -3,29 +3,36 @@ package su.reya.coop.screens import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.InputChip import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedListItem import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -37,9 +44,11 @@ import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back +import coop.composeapp.generated.resources.ic_arrow_next import coop.composeapp.generated.resources.ic_close_small import coop.composeapp.generated.resources.ic_scanner import kotlinx.coroutines.delay @@ -60,7 +69,10 @@ fun NewChatScreen( val snackbarHostState = LocalSnackbarHostState.current val navController = LocalNavController.current val viewModel = LocalNostrViewModel.current + val contactList by viewModel.contactList.collectAsState(initial = emptySet()) + val createGroup = remember { mutableStateOf(false) } + val searchResults = remember { mutableStateListOf() } val selectedReceivers = remember { mutableStateListOf() } var query by remember { mutableStateOf("") } @@ -71,7 +83,28 @@ fun NewChatScreen( LaunchedEffect(query) { if (query.length >= 3) { delay(500) // 500ms debounce - // TODO: Implement search + + if (query.startsWith("npub1")) { + val pubkey = try { + PublicKey.parse(query) + } catch (e: Exception) { + null + } + if (pubkey != null) { + selectedReceivers.add(pubkey) + } + } else if (query.contains("@")) { + val pubkey = viewModel.searchByAddress(query) + if (pubkey != null) { + selectedReceivers.add(pubkey) + } + } else { + val results = viewModel.searchByNostr(query) + searchResults.clear() + searchResults.addAll(results) + } + + query = "" } } @@ -87,7 +120,12 @@ fun NewChatScreen( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( - title = { Text("New Chat") }, + title = { + Text( + text = if (createGroup.value) "New group chat" else "New chat", + style = MaterialTheme.typography.titleMediumEmphasized + ) + }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainer, ), @@ -109,6 +147,37 @@ fun NewChatScreen( } ) }, + floatingActionButton = { + if (selectedReceivers.isNotEmpty()) { + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Above, + spacingBetweenTooltipAndAnchor = 8.dp, + ), + tooltip = { + PlainTooltip { Text("Next") } + }, + state = rememberTooltipState(), + ) { + ExtendedFloatingActionButton( + onClick = { + val roomId = viewModel.createChatRoom(selectedReceivers.toList()) + navController.navigate(Screen.Chat(roomId)) + }, + expanded = false, + icon = { + Icon( + painter = painterResource(Res.drawable.ic_arrow_next), + contentDescription = "Next" + ) + }, + text = { Text("Next") }, + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = MaterialTheme.colorScheme.onTertiary, + ) + } + } + }, content = { innerPadding -> Column( modifier = Modifier @@ -119,52 +188,57 @@ fun NewChatScreen( modifier = Modifier .fillMaxWidth() .padding(16.dp), - shape = RoundedCornerShape(28.dp), + shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surface, ) { - FlowRow( + Row( modifier = Modifier - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.Top, ) { Text( text = "To:", - modifier = Modifier.align(Alignment.Top), - style = MaterialTheme.typography.labelMediumEmphasized, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - selectedReceivers.forEach { receiver -> - ReceiverChip( - pubkey = receiver, - onRemove = { selectedReceivers.remove(receiver) } + Spacer(modifier = Modifier.size(16.dp)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + selectedReceivers.forEach { receiver -> + ReceiverChip( + pubkey = receiver, + onRemove = { selectedReceivers.remove(receiver) } + ) + } + BasicTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier.fillMaxWidth(), + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + if (query.isEmpty()) { + Text( + "Type a npub or address", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.5f + ) + ) + } + innerTextField() + } + } ) } - BasicTextField( - value = query, - onValueChange = { query = it }, - modifier = Modifier - .widthIn(min = 50.dp) - .align(Alignment.CenterVertically), - textStyle = MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurface - ), - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - decorationBox = { innerTextField -> - Box(contentAlignment = Alignment.CenterStart) { - if (query.isEmpty() && selectedReceivers.isEmpty()) { - Text( - "Type a npub or address", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy( - alpha = 0.5f - ) - ) - } - innerTextField() - } - } - ) } } Spacer(modifier = Modifier.size(16.dp)) @@ -173,15 +247,28 @@ fun NewChatScreen( .fillMaxWidth() .padding(horizontal = 16.dp), ) { - // TODO: add result list - ContactList( - selectedReceivers = selectedReceivers, - onContactClick = { pubkey -> - val roomId = viewModel.createChatRoom(listOf(pubkey)) - navController.navigate(Screen.Chat(roomId)) - } - ) - Spacer(modifier = Modifier.size(16.dp)) + if (searchResults.isNotEmpty()) { + ContactList( + items = searchResults, + selectedReceivers = selectedReceivers, + onContactClick = { pubkey -> + val roomId = viewModel.createChatRoom(listOf(pubkey)) + navController.navigate(Screen.Chat(roomId)) + }, + ) + Spacer(modifier = Modifier.size(16.dp)) + } else { + ContactList( + title = "Contacts", + items = contactList.toList(), + selectedReceivers = selectedReceivers, + onContactClick = { pubkey -> + val roomId = viewModel.createChatRoom(listOf(pubkey)) + navController.navigate(Screen.Chat(roomId)) + } + ) + Spacer(modifier = Modifier.size(16.dp)) + } } } } @@ -201,49 +288,62 @@ fun ReceiverChip( val displayName = profile?.name ?: profile?.displayName ?: pubkey.short() val picture = profile?.picture - InputChip( - selected = true, - onClick = onRemove, - label = { Text(displayName) }, - avatar = { - Avatar( - picture = picture, - description = displayName, - size = 24.dp - ) - }, - trailingIcon = { - Icon( - painter = painterResource(Res.drawable.ic_close_small), - contentDescription = "Close" - ) - } - ) + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { + InputChip( + selected = true, + onClick = onRemove, + label = { + Text( + text = displayName, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.SemiBold + ) + ) + }, + avatar = { + Avatar( + picture = picture, + description = displayName, + size = 24.dp + ) + }, + trailingIcon = { + Icon( + painter = painterResource(Res.drawable.ic_close_small), + contentDescription = "Close" + ) + }, + shape = RoundedCornerShape(24.dp), + ) + } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ContactList( + title: String? = null, + items: List, selectedReceivers: SnapshotStateList, onContactClick: (PublicKey) -> Unit ) { - val viewModel = LocalNostrViewModel.current - val contactList by viewModel.contactList.collectAsState(initial = emptySet()) + if (items.isEmpty()) return Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), ) { - Text( - text = "Contacts", - style = MaterialTheme.typography.titleLargeEmphasized, - ) - Spacer(modifier = Modifier.size(8.dp)) - contactList.forEachIndexed { index, item -> + if (title != null) { + Text( + text = title, + style = MaterialTheme.typography.titleMediumEmphasized, + ) + Spacer(modifier = Modifier.size(8.dp)) + } + items.forEachIndexed { index, item -> ContactListItem( pubkey = item, index = index, - total = contactList.size, + total = items.size, isSelected = selectedReceivers.contains(item), onClick = { onContactClick(item) }, onLongClick = { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 0f3a301..48863a9 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -89,6 +89,7 @@ class Nostr { // Bootstrap relays client?.addRelay(RelayUrl.parse("wss://relay.primal.net")) client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) + client?.addRelay(RelayUrl.parse("wss://purplepag.es")) // Add search relay client?.addRelay( @@ -638,18 +639,18 @@ class Nostr { } } - suspend fun searchByAddress(query: String): List { + suspend fun searchByAddress(query: String): PublicKey { try { val address = Nip05Address.parse(query) val profile = profileFromAddress(HttpClient(), address) - return listOf(profile.publicKey()) + return profile.publicKey() } catch (e: Exception) { throw IllegalStateException("Failed to search address: ${e.message}", e) } } - suspend fun searchByNostr(query: String) { + suspend fun searchByNostr(query: String): List { try { val kinds = listOf(Kind.fromStd(KindStandard.METADATA)) val filter = Filter().kinds(kinds).search(query).limit(10u) @@ -672,6 +673,8 @@ class Nostr { results.add(event.event!!.author()) } } + + return results } catch (e: Exception) { throw IllegalStateException("Failed to search nostr: ${e.message}", e) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 861eb83..23d38bd 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -380,6 +380,24 @@ class NostrViewModel( } } } + + suspend fun searchByAddress(query: String): PublicKey? { + try { + return nostr.searchByAddress(query) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + return null + } + + suspend fun searchByNostr(query: String): List { + try { + return nostr.searchByNostr(query) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + return emptyList() + } } fun PublicKey.short(): String { -- 2.49.1 From 955da2fea613d3b7fd3d6a257c71c5f9447ed5a4 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 16 May 2026 15:11:24 +0700 Subject: [PATCH 30/43] update qr code scanner screen --- composeApp/build.gradle.kts | 1 + .../src/androidMain/AndroidManifest.xml | 12 ++- .../su/reya/coop/screens/ImportScreen.kt | 7 +- .../su/reya/coop/screens/NewIdentityScreen.kt | 7 +- .../kotlin/su/reya/coop/screens/ScanScreen.kt | 77 +++++++++++++++++-- 5 files changed, 94 insertions(+), 10 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 4e3d9f4..d3c35bb 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -26,6 +26,7 @@ kotlin { 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") } commonMain.dependencies { implementation(libs.compose.runtime) diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 26403a7..c58475d 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -1,6 +1,13 @@ + + + + + + android:name=".MainActivity" + android:exported="true"> - diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt index 9078a4e..d0a91d0 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -52,7 +52,12 @@ fun ImportScreen( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( - title = { Text("Import") }, + title = { + Text( + text = "Import", + style = MaterialTheme.typography.titleMediumEmphasized + ) + }, navigationIcon = { IconButton(onClick = onBack) { Icon( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt index ca02941..d1f2d4f 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -70,7 +70,12 @@ fun NewIdentityScreen( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( - title = { Text("Create a new identity") }, + title = { + Text( + text = "Create a new identity", + style = MaterialTheme.typography.titleMediumEmphasized + ) + }, navigationIcon = { IconButton(onClick = onBack) { Icon( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt index 041da7e..3aa05ab 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt @@ -1,25 +1,46 @@ 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 @@ -28,9 +49,14 @@ fun ScanScreen( } Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( - title = { Text("Scan QR") }, + title = { + Text( + text = "Scan QR", style = MaterialTheme.typography.titleMediumEmphasized + ) + }, navigationIcon = { IconButton(onClick = onBack) { Icon( @@ -39,11 +65,52 @@ fun ScanScreen( ) } }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + ) ) - } + }, ) { innerPadding -> - Box(modifier = Modifier.padding(innerPadding)) { - Text("Scan QR") + 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 -- 2.49.1 From 1c85e26e7fab27f6816aee78fe4a189f770dd514 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 18 May 2026 12:34:46 +0700 Subject: [PATCH 31/43] add nostr foreground service --- composeApp/build.gradle.kts | 1 + .../src/androidMain/AndroidManifest.xml | 8 ++ .../androidMain/kotlin/su/reya/coop/App.kt | 11 +-- .../kotlin/su/reya/coop/MainActivity.kt | 15 ++- .../su/reya/coop/NostrForegroundService.kt | 93 +++++++++++++++++++ .../commonMain/kotlin/su/reya/coop/Nostr.kt | 76 +++++++++++++-- .../kotlin/su/reya/coop/NostrViewModel.kt | 9 +- 7 files changed, 189 insertions(+), 24 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index d3c35bb..dc58cbf 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -27,6 +27,7 @@ kotlin { 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") } commonMain.dependencies { implementation(libs.compose.runtime) diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index c58475d..3fed69d 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -7,6 +7,9 @@ android:required="false" /> + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 3e826e8..04016c2 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -45,7 +45,7 @@ val LocalNavController = staticCompositionLocalOf { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun App(dbPath: String) { +fun App() { val context = LocalContext.current val navController = rememberNavController() val darkMode = isSystemInDarkTheme() @@ -53,11 +53,10 @@ fun App(dbPath: String) { // Snackbar val snackbarHostState = remember { SnackbarHostState() } - // Initialize Nostr and SecretStore - val nostr = remember { Nostr() } + // Initialize Nostr View Model and Secret Store val secretStore = remember { SecretStore(context) } - val viewModel: NostrViewModel = viewModel { NostrViewModel(nostr, secretStore) } - + 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 -> { @@ -69,7 +68,7 @@ fun App(dbPath: String) { } LaunchedEffect(Unit) { - viewModel.initAndConnect(dbPath) + viewModel.login() viewModel.startNotificationHandler() viewModel.getChatRooms() diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index 4d00430..f4514e1 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -1,22 +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 java.io.File class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) - // Get database directory - val dbDir = File(filesDir, "nostr") - dbDir.mkdirs() + val intent = Intent(this, NostrForegroundService::class.java) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent) + } else { + startService(intent) + } setContent { - App(dbDir.absolutePath) + App() } } } 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..c85197e --- /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) + + // 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/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 48863a9..22aad08 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -52,7 +52,12 @@ import rust.nostr.sdk.initLogger import rust.nostr.sdk.nip17ExtractRelayList import kotlin.time.Duration +object NostrManager { + val instance = Nostr() +} + class Nostr { + private var isInitialized = false var client: Client? = null private set var signer: UniversalSigner = UniversalSigner(Keys.generate()) @@ -64,6 +69,8 @@ class Nostr { suspend fun init(dbPath: String) { try { + if (isInitialized) return + // Initialize the logger for nostr client initLogger(LogLevel.DEBUG) @@ -105,6 +112,8 @@ class Nostr { // Connect to all bootstrap relays and wait for all connections to be established client?.connect(Duration.parse("3s")) + + isInitialized = true } catch (e: Exception) { throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e) } @@ -119,9 +128,9 @@ class Nostr { deviceSigner = null } - suspend fun setSigner(keys: AsyncNostrSigner) { + suspend fun setSigner(new: AsyncNostrSigner) { try { - signer.switch(keys) + signer.switch(new) // Fetch metadata for current user getUserMetadata() } catch (e: Exception) { @@ -184,18 +193,69 @@ class Nostr { client?.subscribe( target = ReqTarget.manual(target), - id = "messages" + 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 -> {} + } + } + + else -> {} + } + } + } + suspend fun handleNotifications( onMetadataUpdate: (PublicKey, Metadata) -> Unit, onContactListUpdate: (List) -> Unit, onNewMessage: (UnsignedEvent) -> Unit, - onEose: () -> Unit, + onSubscriptionClose: () -> Unit, ) = coroutineScope { val now = Timestamp.now() val processedEvent = mutableSetOf() @@ -251,7 +311,7 @@ class Nostr { // Start a new tracker eoseTrackerJob = launch { delay(10000) // Wait for 10 seconds - onEose() + onSubscriptionClose() } // Handle new message @@ -270,7 +330,7 @@ class Nostr { val subscriptionId = message.subscriptionId if (subscriptionId == "messages") { - onEose() + onSubscriptionClose() } } @@ -612,7 +672,9 @@ class Nostr { signer = signer, receiverPubkey = receiver, rumor = rumor, - extraTags = tags + extraTags = listOf( + Tag.custom(TagKind.Unknown("k"), listOf("14")) + ) ) // Send the event to receiver's NIP-17 relays diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 23d38bd..840e09c 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -131,14 +131,11 @@ class NostrViewModel( _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata } - suspend fun initAndConnect(dbPath: String) { + suspend fun login() { try { - // Initialize nostr client - nostr.init(dbPath) - // Get user's secret getUserSecret() } catch (e: Exception) { - showError("Failed to initialize Nostr: ${e.message}") + showError("Failed to login: ${e.message}") } } @@ -151,7 +148,7 @@ class NostrViewModel( onContactListUpdate = { contactList -> _contactList.value = contactList.toSet() }, - onEose = { + onSubscriptionClose = { getChatRooms() }, onNewMessage = { event -> -- 2.49.1 From 5903de7e827442c23f4d4bb79451e5d73b201d27 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 18 May 2026 17:39:43 +0700 Subject: [PATCH 32/43] add check sent message --- .../kotlin/su/reya/coop/screens/ChatScreen.kt | 15 ++++- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 67 ++++++++++++------- .../kotlin/su/reya/coop/NostrViewModel.kt | 35 ++++++++-- .../commonMain/kotlin/su/reya/coop/Room.kt | 8 --- 4 files changed, 84 insertions(+), 41 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index 2db5b00..b945c7e 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -1,5 +1,6 @@ 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 @@ -75,7 +76,7 @@ fun ChatScreen( val groupedMessages = remember(messages.toList()) { messages.groupBy { it.createdAt().formatAsGroupHeader() } } - + fun setLoading(value: Boolean) { loading = value } @@ -240,7 +241,17 @@ fun ChatMessage( color = containerColor, contentColor = contentColor, shape = bubbleShape, - modifier = Modifier.widthIn(max = 280.dp) + 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(), diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 22aad08..20c7e6d 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -66,6 +66,10 @@ class Nostr { private set var msgRelayList: Map> = emptyMap() private set + var sentEvents: MutableMap> = mutableMapOf() + private set + var rumorMap: MutableMap = mutableMapOf() + private set suspend fun init(dbPath: String) { try { @@ -94,6 +98,7 @@ class Nostr { .build() // Bootstrap relays + client?.addRelay(RelayUrl.parse("wss://relay.damus.io")) client?.addRelay(RelayUrl.parse("wss://relay.primal.net")) client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) client?.addRelay(RelayUrl.parse("wss://purplepag.es")) @@ -334,6 +339,13 @@ class Nostr { } } + is RelayMessageEnum.Ok -> { + if (sentEvents.containsKey(message.eventId)) { + val currentRelays = sentEvents[message.eventId] ?: emptyList() + sentEvents[message.eventId] = currentRelays + relayUrl + } + } + else -> { /* Ignore other message types */ } @@ -495,7 +507,7 @@ class Nostr { client?.sendEvent( event = metadataEvent, - target = SendEventTarget.toNip65(), + target = SendEventTarget.broadcast(), ackPolicy = AckPolicy.none() ) @@ -515,7 +527,7 @@ class Nostr { suspend fun fetchMetadataBatch(keys: List) { try { - val limit = keys.size.toULong(); + val limit = keys.size.toULong() * 4u; val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) // Construct a filter for metadata events @@ -528,8 +540,10 @@ class Nostr { 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) + RelayUrl.parse("wss://relay.primal.net") to listOf(filter), + RelayUrl.parse("wss://relay.damus.io") to listOf(filter), ) ) @@ -550,37 +564,31 @@ class Nostr { val events = client?.database()?.query(filter) // Collect rooms - val rooms: MutableSet = mutableSetOf() + val roomsMap: MutableMap = mutableMapOf() events ?.toVec() ?.map { UnsignedEvent.fromJson(it.content()) } ?.filter { it.tags().publicKeys().isNotEmpty() } - ?.sortedByDescending { it.createdAt().asSecs() } ?.forEach { event -> - val room = Room.new(rumor = event, userPubkey = userPubkey) + val newRoom = Room.new(rumor = event, userPubkey = userPubkey) + val existingRoom = roomsMap[newRoom.id] // Check if the room already exists - if (rooms.contains(room)) { - room.setCreatedAt(room.createdAt) - room.setLastMessage(room.lastMessage) + 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 } - - val filter = - Filter().kind(kind).author(userPubkey).pubkeys(room.members.toList()); - - // Check if the user is interacting with the room's members - val isOngoing = client?.database()?.query(filter)?.isEmpty() == false; - - // Set the room kind based on interaction status - if (isOngoing) { - room.setKind(RoomKind.Ongoing) - } - - rooms.add(room) } - return rooms + return roomsMap.values.toSet() } catch (e: Exception) { println("Failed to get chat rooms: ${e.message}") return null @@ -625,7 +633,7 @@ class Nostr { content: String, subject: String? = null, replies: List = emptyList(), - onNewMessage: ((UnsignedEvent) -> Unit)? = null + onRumorCreated: ((UnsignedEvent) -> Unit)? = null, ) { try { val currentUser = @@ -664,7 +672,7 @@ class Nostr { // Emit the rumor to the chat screen if (receiver == currentUser) { - onNewMessage?.invoke(rumor) + onRumorCreated?.invoke(rumor) } // Construct the gift wrap event @@ -678,12 +686,19 @@ class Nostr { ) // Send the event to receiver's NIP-17 relays - client?.sendEvent( + val output = client?.sendEvent( event = gift, target = SendEventTarget.toNip17(), ackPolicy = AckPolicy.none(), authenticationTimeout = Duration.parse("2s") ) + + if (output != null) { + sentEvents[output.id] = emptyList() + if (rumor.id() != null) { + rumorMap[rumor.id()!!] = output.id + } + } } } catch (e: Exception) { throw IllegalStateException("Failed to send message: ${e.message}", e) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 840e09c..a680e41 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -24,6 +24,7 @@ import rust.nostr.sdk.Metadata import rust.nostr.sdk.NostrConnect import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.Tag import rust.nostr.sdk.UnsignedEvent import su.reya.coop.blossom.BlossomClient @@ -50,6 +51,9 @@ class NostrViewModel( 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() @@ -104,6 +108,7 @@ class NostrViewModel( if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) { val keysToRequest = batch.toList() batch.clear() + nostr.fetchMetadataBatch(keysToRequest) } } @@ -366,11 +371,10 @@ class NostrViewModel( content = message, subject = room.subject, replies = replies, - onNewMessage = { event -> - viewModelScope.launch { - _newEvents.emit(event) - } - } + onRumorCreated = { event -> + updateRoomList(roomId, event) + viewModelScope.launch { _newEvents.emit(event) } + }, ) } catch (e: Exception) { showError("Error: ${e.message}") @@ -378,6 +382,27 @@ class NostrViewModel( } } + 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) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index 72f8e98..3e2ff19 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -29,14 +29,6 @@ data class Room( val kind: RoomKind = RoomKind.default(), val lastMessage: String? = null ) : Comparable { - override fun hashCode(): Int = id.hashCode() - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is Room) return false - return id == other.id - } - override fun compareTo(other: Room): Int { return this.createdAt.asSecs().compareTo(other.createdAt.asSecs()) } -- 2.49.1 From 08374fed49c3abc46539775ed0c7a89f1559bffd Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 19 May 2026 08:26:50 +0700 Subject: [PATCH 33/43] load all cache metadata on startup --- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 52 +++++++++++++++++-- .../kotlin/su/reya/coop/NostrViewModel.kt | 27 ++++++---- 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 20c7e6d..ad93f65 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -278,6 +278,7 @@ class Nostr { 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 @@ -299,9 +300,18 @@ class Nostr { } if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) { + // Get all gift wrap events for current user if (isSignedByUser(event = event)) { getUserMessages(msgRelayList = event) } + + // Connect to all msg relays for the currently active chat room + if (id.startsWith("room-")) { + launch { + chatRoomAuth(event) + } + } + // Cache the relay list for future use setMsgRelay(pubkey = event.author(), event = event) } @@ -525,6 +535,23 @@ class Nostr { 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; @@ -611,7 +638,7 @@ class Nostr { } } - suspend fun chatRoomConnect(members: List) { + suspend fun chatRoomConnect(id: Long, members: List) { try { members.forEach { member -> val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) @@ -620,7 +647,8 @@ class Nostr { client?.subscribe( target = ReqTarget.auto(listOf(filter)), - closeOn = opts + closeOn = opts, + id = "room-${id}" ) } } catch (e: Exception) { @@ -628,6 +656,18 @@ class Nostr { } } + suspend fun chatRoomAuth(event: Event) { + try { + val urls = nip17ExtractRelayList(event); + for (url in urls) { + client?.addRelay(url) + client?.connectRelay(url) + } + } catch (e: Exception) { + throw IllegalStateException("Failed to authenticate chat room: ${e.message}", e) + } + } + suspend fun sendMessage( to: List, content: String, @@ -694,9 +734,13 @@ class Nostr { ) if (output != null) { + // Keep track of sent events sentEvents[output.id] = emptyList() - if (rumor.id() != null) { - rumorMap[rumor.id()!!] = output.id + 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") } } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index a680e41..036a523 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -63,6 +63,7 @@ class NostrViewModel( init { startMetadataBatchProcessor() + getCacheMetadata() } override fun onCleared() { @@ -78,10 +79,7 @@ class NostrViewModel( private fun showError(message: String) { viewModelScope.launch { _errorEvents.send(message) - - if (isCreating.value) { - _isCreating.value = false - } + if (isCreating.value) _isCreating.value = false } } @@ -116,6 +114,17 @@ class NostrViewModel( } } + private fun getCacheMetadata() { + viewModelScope.launch { + val results = nostr.getAllCacheMetadata() + results.forEach { (pubkey, metadata) -> + println("Cache metadata for pubkey $pubkey: $metadata") + updateMetadata(pubkey, metadata) + seenPublicKeys.add(pubkey) + } + } + } + private fun requestMetadata(pubkey: PublicKey) { if (seenPublicKeys.add(pubkey)) { viewModelScope.launch { @@ -124,6 +133,10 @@ class NostrViewModel( } } + 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) { @@ -132,10 +145,6 @@ class NostrViewModel( return flow.asStateFlow() } - private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) { - _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata - } - suspend fun login() { try { getUserSecret() @@ -355,7 +364,7 @@ class NostrViewModel( val room = getChatRoom(roomId) val members = room.members - nostr.chatRoomConnect(members.toList()) + nostr.chatRoomConnect(roomId, members.toList()) } catch (e: Exception) { showError("Error: ${e.message}") } -- 2.49.1 From fd64998fd83dba09fe184a884e6ca2deaccefa79 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 19 May 2026 08:58:03 +0700 Subject: [PATCH 34/43] refactor --- .../androidMain/kotlin/su/reya/coop/App.kt | 8 - .../su/reya/coop/NostrForegroundService.kt | 4 +- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 84 +++++++--- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 61 ++++--- .../kotlin/su/reya/coop/NostrViewModel.kt | 157 +++++++++--------- 5 files changed, 178 insertions(+), 136 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 04016c2..a5f5223 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -68,11 +68,6 @@ fun App() { } LaunchedEffect(Unit) { - viewModel.login() - viewModel.startNotificationHandler() - viewModel.getChatRooms() - - // Collect error events from the ViewModel viewModel.errorEvents.collect { message -> snackbarHostState.showSnackbar(message) } @@ -91,9 +86,6 @@ fun App() { LaunchedEffect(emptySecret) { // Navigate to the home screen if the secret is already set if (emptySecret == false) { - // Get chat rooms - viewModel.getChatRooms() - // Navigate to the home screen navController.navigate(Screen.Home) { popUpTo(Screen.Onboarding) { inclusive = true } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt index c85197e..4d53f16 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt @@ -38,10 +38,10 @@ class NostrForegroundService : Service() { 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()) { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 1dfed82..4f0ae20 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -37,10 +37,14 @@ 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 @@ -78,7 +82,6 @@ fun HomeScreen( val clipboard = LocalClipboard.current val snackbarHostState = LocalSnackbarHostState.current val viewModel = LocalNostrViewModel.current - val scope = rememberCoroutineScope() val currentUser = viewModel.currentUser() ?: return val currentUserProfile = viewModel.getMetadata(currentUser) ?: return @@ -86,10 +89,17 @@ fun HomeScreen( val userProfile by currentUserProfile.collectAsState(initial = null) val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList()) + 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) } + + LaunchedEffect(Unit) { + viewModel.getChatRooms() + } Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -165,34 +175,54 @@ fun HomeScreen( color = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), ) { - if (chatRooms.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = "No chats yet", - style = MaterialTheme.typography.titleLargeEmphasized, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = "Your conversations will appear here.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.outline - ) + 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), + ) } - } else { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize() - ) { - items(chatRooms.toList(), key = { it.id }) { room -> - ChatRoom( - room = room, - onClick = { onOpenChat(room.id) } - ) + ) { + if (chatRooms.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "No chats yet", + style = MaterialTheme.typography.titleLargeEmphasized, + 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) } + ) + } } } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index ad93f65..a7fc1a5 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -8,6 +8,10 @@ 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 @@ -57,7 +61,9 @@ object NostrManager { } class Nostr { - private var isInitialized = false + private val _isInitialized = MutableStateFlow(false) + val isInitialized: StateFlow = _isInitialized.asStateFlow() + var client: Client? = null private set var signer: UniversalSigner = UniversalSigner(Keys.generate()) @@ -73,7 +79,7 @@ class Nostr { suspend fun init(dbPath: String) { try { - if (isInitialized) return + if (_isInitialized.value) return // Initialize the logger for nostr client initLogger(LogLevel.DEBUG) @@ -97,33 +103,33 @@ class Nostr { .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) .build() - // Bootstrap relays - client?.addRelay(RelayUrl.parse("wss://relay.damus.io")) - client?.addRelay(RelayUrl.parse("wss://relay.primal.net")) - client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) - client?.addRelay(RelayUrl.parse("wss://purplepag.es")) - - // Add search relay - client?.addRelay( - url = RelayUrl.parse("wss://antiprimal.net"), - capabilities = RelayCapabilities.read() - ) - - // 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("3s")) - - isInitialized = true + _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() } @@ -773,6 +779,13 @@ class Nostr { 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 = diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 036a523..0ae1fbc 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -62,8 +62,10 @@ class NostrViewModel( private val seenPublicKeys = mutableSetOf() init { - startMetadataBatchProcessor() + startNotificationHandler() + startMetadataBatchHandler() getCacheMetadata() + login() } override fun onCleared() { @@ -83,8 +85,35 @@ class NostrViewModel( } } - private fun startMetadataBatchProcessor() { + 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() + }, + 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 @@ -116,15 +145,56 @@ class NostrViewModel( private fun getCacheMetadata() { viewModelScope.launch { + // Wait until the client is ready + nostr.waitUntilInitialized() + val results = nostr.getAllCacheMetadata() results.forEach { (pubkey, metadata) -> - println("Cache metadata for pubkey $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 requestMetadata(pubkey: PublicKey) { if (seenPublicKeys.add(pubkey)) { viewModelScope.launch { @@ -145,82 +215,11 @@ class NostrViewModel( return flow.asStateFlow() } - suspend fun login() { - try { - getUserSecret() - } catch (e: Exception) { - showError("Failed to login: ${e.message}") - } - } - - fun startNotificationHandler() { - viewModelScope.launch { - nostr.handleNotifications( - onMetadataUpdate = { pubkey, metadata -> - updateMetadata(pubkey, metadata) - }, - onContactListUpdate = { contactList -> - _contactList.value = contactList.toSet() - }, - onSubscriptionClose = { - getChatRooms() - }, - onNewMessage = { event -> - viewModelScope.launch { - _newEvents.emit(event) - } - }, - ) - } - } - fun currentUser(): PublicKey? { return nostr.signer.currentUser } - fun logout() { - viewModelScope.launch { - _emptySecret.value = true - _chatRooms.value = emptySet() - secretStore.clear("user_signer") - nostr.exit() - } - } - - suspend fun getUserSecret() { - // 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 - } - - 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") - } - } - - suspend fun getOrInitAppKeys(): Keys { + private suspend fun getOrInitAppKeys(): Keys { val secret = secretStore.get("app_keys") // If app keys are already stored, use them @@ -348,6 +347,14 @@ class NostrViewModel( } } + 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) -- 2.49.1 From 0104cf453db637cc3b924b02c1d993df66a3d62b Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 19 May 2026 09:53:41 +0700 Subject: [PATCH 35/43] update scan qr screen --- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 73 ++++++++++++++----- .../su/reya/coop/screens/NewChatScreen.kt | 12 ++- .../kotlin/su/reya/coop/screens/ScanScreen.kt | 4 +- .../kotlin/su/reya/coop/NostrViewModel.kt | 8 ++ 4 files changed, 72 insertions(+), 25 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 4f0ae20..a3ca0f0 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -15,9 +15,11 @@ 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.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem @@ -61,12 +63,14 @@ import androidx.compose.ui.unit.dp import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_new_chat import coop.composeapp.generated.resources.ic_scanner -import coop.composeapp.generated.resources.ic_search import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource +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 @@ -80,6 +84,7 @@ fun HomeScreen( onNewChat: () -> Unit, ) { val clipboard = LocalClipboard.current + val navController = LocalNavController.current val snackbarHostState = LocalSnackbarHostState.current val viewModel = LocalNostrViewModel.current @@ -97,10 +102,30 @@ fun HomeScreen( 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, @@ -116,15 +141,8 @@ fun HomeScreen( ) }, actions = { - // Search - IconButton(onClick = { /* TODO: Open search */ }) { - Icon( - painter = painterResource(Res.drawable.ic_search), - contentDescription = "Search" - ) - } // QR Scanner - IconButton(onClick = { /* TODO: Open search */ }) { + IconButton(onClick = { navController.navigate(Screen.Scan) }) { Icon( painter = painterResource(Res.drawable.ic_scanner), contentDescription = "Scanner" @@ -353,20 +371,37 @@ val defaultMenuList = listOf( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun BottomMenuList() { + val viewModel = LocalNostrViewModel.current + Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + horizontalAlignment = Alignment.CenterHorizontally, ) { - defaultMenuList.forEachIndexed { index, item -> - SegmentedListItem( - checked = false, - onCheckedChange = { }, - shapes = ListItemDefaults.segmentedShapes( - index = index, - count = defaultMenuList.size - ), - content = { Text(text = item) }, + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + defaultMenuList.forEachIndexed { index, item -> + SegmentedListItem( + checked = false, + onCheckedChange = { }, + shapes = ListItemDefaults.segmentedShapes( + index = index, + count = defaultMenuList.size + ), + content = { Text(text = item) }, + ) + } + } + 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/NewChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt index bdf3307..f669836 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt @@ -77,7 +77,9 @@ fun NewChatScreen( var query by remember { mutableStateOf("") } val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle - val qrResult by savedStateHandle?.getStateFlow("qr_result", null)?.collectAsState() + val qrResult by savedStateHandle + ?.getStateFlow("qr_result", null) + ?.collectAsState() ?: remember { mutableStateOf(null) } LaunchedEffect(query) { @@ -88,6 +90,7 @@ fun NewChatScreen( val pubkey = try { PublicKey.parse(query) } catch (e: Exception) { + println("Failed to parse npub: ${e.message}") null } if (pubkey != null) { @@ -109,8 +112,11 @@ fun NewChatScreen( } LaunchedEffect(qrResult) { - qrResult?.let { - println("QR result: $it") + 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") } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt index 3aa05ab..1386c22 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt @@ -42,9 +42,7 @@ fun ScanScreen( val snackbarHostState = LocalSnackbarHostState.current val onResult: (String) -> Unit = { result -> - navController.previousBackStackEntry - ?.savedStateHandle - ?.set("qr_result", result) + navController.previousBackStackEntry?.savedStateHandle?.set("qr_result", result) navController.popBackStack() } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 0ae1fbc..4dd2ff1 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -219,6 +219,14 @@ class NostrViewModel( return nostr.signer.currentUser } + fun logout() { + viewModelScope.launch { + secretStore.clear("user_signer") + nostr.signer.switch(Keys.generate()) + _emptySecret.value = true + } + } + private suspend fun getOrInitAppKeys(): Keys { val secret = secretStore.get("app_keys") -- 2.49.1 From e5710f376e78395e9dd9d73eae3f290a836ea632 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 19 May 2026 16:00:41 +0700 Subject: [PATCH 36/43] refactor chat room connect --- .../kotlin/su/reya/coop/screens/ChatScreen.kt | 78 +++++++++++++------ .../kotlin/su/reya/coop/screens/HomeScreen.kt | 23 +++--- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 71 +++++++++-------- .../kotlin/su/reya/coop/NostrViewModel.kt | 17 ++-- 4 files changed, 109 insertions(+), 80 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index b945c7e..e5515a3 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -47,6 +47,7 @@ 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 @@ -56,6 +57,7 @@ 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( @@ -91,7 +93,16 @@ fun ChatScreen( messages.addAll(initialMessages) // Get msg relays for each member - viewModel.chatRoomConnect(id) + 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) @@ -113,7 +124,11 @@ fun ChatScreen( TopAppBar( title = { Row(verticalAlignment = Alignment.CenterVertically) { - Box { + if (loading) { + LoadingIndicator( + modifier = Modifier.size(32.dp), + ) + } else { Avatar( picture = picture, description = displayName, @@ -148,19 +163,12 @@ fun ChatScreen( color = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), ) { - if (loading) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - LoadingIndicator() - } - } else { - Column( - modifier = Modifier - .fillMaxSize() - .padding(bottom = innerPadding.calculateBottomPadding()) - ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(bottom = innerPadding.calculateBottomPadding()) + ) { + if (groupedMessages.isNotEmpty()) { LazyColumn( modifier = Modifier .weight(1f) @@ -169,7 +177,9 @@ fun ChatScreen( reverseLayout = true ) { groupedMessages.forEach { (dateHeader, messagesInGroup) -> - items(messagesInGroup, key = { it.id()?.toBech32()!! }) { event -> + items( + messagesInGroup, + key = { it.id()?.toBech32()!! }) { event -> ChatMessage(event) } item { @@ -177,15 +187,33 @@ fun ChatScreen( } } } - ChatInput( - value = text, - onValueChange = { text = it }, - onSend = { - viewModel.sendMessage(id, text) - text = "" + } else { + Box( + modifier = Modifier.fillMaxSize(), + 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 = "" + } + ) } } } @@ -223,10 +251,10 @@ fun ChatMessage( } val containerColor = - if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer + if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.tertiaryContainer val contentColor = - if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer + if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onTertiaryContainer Box( modifier = Modifier diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index a3ca0f0..e7bc625 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -360,19 +360,19 @@ fun ChatRoom(room: Room, onClick: () -> Unit) { ) } -val defaultMenuList = listOf( - "Messaging Relays", - "Spam Filter", - "Contacts", - "Settings", - "About" -) - @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun BottomMenuList() { val viewModel = LocalNostrViewModel.current + val defaultMenuList = listOf( + "Messaging Relays" to { }, + "Spam Filter" to { }, + "Contacts" to { }, + "Settings" to { }, + "About" to { } + ) + Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, @@ -381,15 +381,14 @@ fun BottomMenuList() { modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), ) { - defaultMenuList.forEachIndexed { index, item -> + defaultMenuList.forEachIndexed { index, (title, action) -> SegmentedListItem( - checked = false, - onCheckedChange = { }, + onClick = { action() }, shapes = ListItemDefaults.segmentedShapes( index = index, count = defaultMenuList.size ), - content = { Text(text = item) }, + content = { Text(text = title) }, ) } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index a7fc1a5..afe37c0 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -70,8 +70,6 @@ class Nostr { private set var deviceSigner: AsyncNostrSigner? = null private set - var msgRelayList: Map> = emptyMap() - private set var sentEvents: MutableMap> = mutableMapOf() private set var rumorMap: MutableMap = mutableMapOf() @@ -253,11 +251,15 @@ class Nostr { } } - else -> {} + else -> { + /* Ignore other event kinds */ + } } } - else -> {} + else -> { + /* Ignore other message types */ + } } } } @@ -306,20 +308,10 @@ class Nostr { } if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) { - // Get all gift wrap events for current user + // Get all gift wrap events for the current user if (isSignedByUser(event = event)) { getUserMessages(msgRelayList = event) } - - // Connect to all msg relays for the currently active chat room - if (id.startsWith("room-")) { - launch { - chatRoomAuth(event) - } - } - - // Cache the relay list for future use - setMsgRelay(pubkey = event.author(), event = event) } if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) { @@ -368,22 +360,17 @@ class Nostr { } } - is ClientNotification.NewEvent -> { - // TODO: Handle new event - } - is ClientNotification.Shutdown -> { break } + + else -> { + /* Ignore other message types */ + } } } } - private fun setMsgRelay(pubkey: PublicKey, event: Event) { - val relays = nip17ExtractRelayList(event) - msgRelayList = msgRelayList + (pubkey to relays) - } - private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? { try { val filter = Filter().identifier(giftId.toHex()) @@ -644,33 +631,49 @@ class Nostr { } } - suspend fun chatRoomConnect(id: Long, members: List) { + 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 opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) - client?.subscribe( + val stream = client?.streamEvents( target = ReqTarget.auto(listOf(filter)), - closeOn = opts, - id = "room-${id}" + 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 connect to chat room: ${e.message}", e) + throw IllegalStateException("Failed to fetch relays: ${e.message}", e) } } - suspend fun chatRoomAuth(event: Event) { + suspend fun connectMsgRelays(event: Event) { try { val urls = nip17ExtractRelayList(event); for (url in urls) { - client?.addRelay(url) - client?.connectRelay(url) + if (client?.relay(url) == null) { + client?.addRelay(url) + client?.connectRelay(url) + } } } catch (e: Exception) { - throw IllegalStateException("Failed to authenticate chat room: ${e.message}", e) + throw IllegalStateException("Failed to connect to relays: ${e.message}", e) } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 4dd2ff1..b604736 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -373,16 +373,15 @@ class NostrViewModel( return emptyList() } - fun chatRoomConnect(roomId: Long) { - viewModelScope.launch { - try { - val room = getChatRoom(roomId) - val members = room.members + suspend fun chatRoomConnect(roomId: Long): Map> { + val room = getChatRoom(roomId) + val members = room.members - nostr.chatRoomConnect(roomId, members.toList()) - } catch (e: Exception) { - showError("Error: ${e.message}") - } + return runCatching { + nostr.chatRoomConnect(members.toList()) + }.getOrElse { e -> + showError("Error: ${e.message}") + members.associateWith { emptyList() } } } -- 2.49.1 From 43116958a258ceac38748fb0855bcf7595dc1dc8 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 19 May 2026 21:06:02 +0700 Subject: [PATCH 37/43] fix chat screen --- .../androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index e5515a3..4c3a365 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -168,7 +168,7 @@ fun ChatScreen( .fillMaxSize() .padding(bottom = innerPadding.calculateBottomPadding()) ) { - if (groupedMessages.isNotEmpty()) { + if (messages.isNotEmpty()) { LazyColumn( modifier = Modifier .weight(1f) @@ -189,7 +189,9 @@ fun ChatScreen( } } else { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .weight(1f) + .fillMaxWidth(), contentAlignment = Alignment.Center ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { -- 2.49.1 From e6dff5277d9b0ad42022e71828349d5abc736026 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 20 May 2026 16:55:57 +0700 Subject: [PATCH 38/43] update new identity screen --- .../composeResources/drawable/ic_plus.xml | 9 + .../kotlin/su/reya/coop/screens/ChatScreen.kt | 12 +- .../su/reya/coop/screens/NewIdentityScreen.kt | 162 ++++++++++++------ .../commonMain/kotlin/su/reya/coop/Nostr.kt | 14 +- .../kotlin/su/reya/coop/NostrViewModel.kt | 6 +- 5 files changed, 147 insertions(+), 56 deletions(-) create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_plus.xml 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/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index 4c3a365..b016fd3 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -17,6 +17,7 @@ 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 @@ -68,6 +69,8 @@ fun ChatScreen( 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) @@ -117,6 +120,12 @@ fun ChatScreen( } } + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) { + listState.animateScrollToItem(0) + } + } + Scaffold( containerColor = MaterialTheme.colorScheme.surfaceContainer, snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -174,7 +183,8 @@ fun ChatScreen( .weight(1f) .fillMaxWidth(), contentPadding = PaddingValues(16.dp), - reverseLayout = true + reverseLayout = true, + state = listState, ) { groupedMessages.forEach { (dateHeader, messagesInGroup) -> items( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt index d1f2d4f..5f9d71a 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -14,8 +14,8 @@ 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.CircleShape 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 @@ -23,14 +23,15 @@ 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.OutlinedTextField 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 @@ -39,12 +40,14 @@ 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_avatar +import coop.composeapp.generated.resources.ic_plus import org.jetbrains.compose.resources.painterResource import su.reya.coop.LocalSnackbarHostState @@ -53,7 +56,7 @@ import su.reya.coop.LocalSnackbarHostState fun NewIdentityScreen( isLoading: Boolean, onBack: () -> Unit, - onSave: (name: String, bio: String, picture: Uri?) -> Unit + onSave: (name: String, bio: String?, picture: Uri?) -> Unit ) { val snackbarHostState = LocalSnackbarHostState.current var name by remember { mutableStateOf("") } @@ -90,25 +93,21 @@ fun NewIdentityScreen( ) }, 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(), ) { Column( modifier = Modifier - .fillMaxSize() - .padding(24.dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) + .weight(1f) + .fillMaxWidth() + .padding(top = innerPadding.calculateTopPadding()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { Box( modifier = Modifier .size(120.dp) - .clip(CircleShape) + .clip(MaterialShapes.Pentagon.toShape()) .clickable { launcher.launch("image/*") }, contentAlignment = Alignment.Center ) { @@ -127,7 +126,7 @@ fun NewIdentityScreen( ) { Box(contentAlignment = Alignment.Center) { Icon( - painter = painterResource(Res.drawable.ic_avatar), + painter = painterResource(Res.drawable.ic_plus), contentDescription = "Pick avatar", modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant @@ -136,39 +135,106 @@ fun NewIdentityScreen( } } } - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Name") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - OutlinedTextField( - value = bio, - onValueChange = { bio = it }, - label = { Text("Bio:") }, + } + Surface( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + ) { + Column( modifier = Modifier - .fillMaxWidth() - .height(150.dp), - minLines = 3, - ) - Spacer(modifier = Modifier.weight(1f)) - Button( - onClick = { - onSave(name, bio, picture) - }, - modifier = Modifier - .fillMaxWidth() - .height(ButtonDefaults.LargeContainerHeight), - enabled = name.isNotBlank() && !isLoading, + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - if (isLoading) { - LoadingIndicator() - } else { - Text( - text = "Save & Continue", - style = MaterialTheme.typography.titleLargeEmphasized, - ) + 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.LargeContainerHeight), + enabled = name.isNotBlank() && !isLoading, + ) { + if (isLoading) { + LoadingIndicator() + } else { + Text( + text = "Continue", + style = MaterialTheme.typography.titleLargeEmphasized, + ) + } } } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index afe37c0..28fbe0f 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -95,7 +95,13 @@ class Nostr { .websocketTransport(CoopWebSocketClient(httpClient)) .database(lmdb) .gossip(gossip) - .gossipConfig(GossipConfig().noBackgroundRefresh()) + .gossipConfig( + GossipConfig() + .noBackgroundRefresh() + .fetchTimeout(Duration.parse("2s")) + .syncIdleTimeout(Duration.parse("100ms")) + .syncInitialTimeout(Duration.parse("100ms")) + ) .verifySubscriptions(false) .automaticAuthentication(true) .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) @@ -481,7 +487,7 @@ class Nostr { return msgRelayList } - suspend fun createIdentity(keys: Keys, name: String, bio: String, picture: String?) { + 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); @@ -505,7 +511,7 @@ class Nostr { // Send metadata event val metadata = - Metadata.fromRecord(MetadataRecord(name = name, about = bio, picture = picture)) + Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture)) val metadataEvent = EventBuilder.metadata(metadata).signWithKeys(keys) client?.sendEvent( @@ -608,7 +614,7 @@ class Nostr { } } - return roomsMap.values.toSet() + return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet() } catch (e: Exception) { println("Failed to get chat rooms: ${e.message}") return null diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index b604736..adb1846 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -244,9 +244,9 @@ class NostrViewModel( fun createIdentity( name: String, - bio: String, + bio: String?, picture: ByteArray?, - contentType: String? + contentType: String? = null ) { viewModelScope.launch { try { @@ -282,7 +282,7 @@ class NostrViewModel( } // Create identity - nostr.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl) + nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl) // Save secret to the secret storage secretStore.set("user_signer", secret) -- 2.49.1 From 92f681e2fa101eb9000a575f4e75ba3d530379f6 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 21 May 2026 10:30:27 +0700 Subject: [PATCH 39/43] update import screen --- .../su/reya/coop/screens/ImportScreen.kt | 210 +++++++++++++++--- .../su/reya/coop/screens/NewChatScreen.kt | 2 +- .../su/reya/coop/screens/NewIdentityScreen.kt | 13 +- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 1 - .../kotlin/su/reya/coop/NostrViewModel.kt | 19 ++ 5 files changed, 200 insertions(+), 45 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt index d0a91d0..57f2c51 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -1,14 +1,17 @@ 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 @@ -16,26 +19,46 @@ 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.OutlinedTextField 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 @@ -45,7 +68,48 @@ fun ImportScreen( 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, @@ -58,6 +122,9 @@ fun ImportScreen( style = MaterialTheme.typography.titleMediumEmphasized ) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), navigationIcon = { IconButton(onClick = onBack) { Icon( @@ -66,51 +133,120 @@ fun ImportScreen( ) } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ) + actions = { + IconButton(onClick = { navController.navigate(Screen.Scan) }) { + Icon( + painter = painterResource(Res.drawable.ic_scanner), + contentDescription = "Scanner" + ) + } + } ) }, 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(), ) { Column( modifier = Modifier - .fillMaxSize() - .padding(24.dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) + .weight(1f) + .fillMaxWidth() + .padding(top = innerPadding.calculateTopPadding()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { - OutlinedTextField( - value = secret, - onValueChange = { secret = it }, - label = { Text("Enter nsec or bunker") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - Spacer(modifier = Modifier.weight(1f)) - Button( - onClick = { - onSave(secret) - }, + Box( modifier = Modifier - .fillMaxWidth() - .height(ButtonDefaults.LargeContainerHeight), - enabled = secret.isNotBlank() && !isLoading, + .size(120.dp) + .clip(MaterialShapes.Pentagon.toShape()), + contentAlignment = Alignment.Center ) { - if (isLoading) { - LoadingIndicator() - } else { - Text( - text = "Save & Continue", - style = MaterialTheme.typography.titleLargeEmphasized, - ) + 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/NewChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt index f669836..e80ce27 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt @@ -69,8 +69,8 @@ fun NewChatScreen( val snackbarHostState = LocalSnackbarHostState.current val navController = LocalNavController.current val viewModel = LocalNostrViewModel.current - val contactList by viewModel.contactList.collectAsState(initial = emptySet()) + val contactList by viewModel.contactList.collectAsState(initial = emptySet()) val createGroup = remember { mutableStateOf(false) } val searchResults = remember { mutableStateListOf() } val selectedReceivers = remember { mutableStateListOf() } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt index 5f9d71a..654485b 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -63,10 +63,11 @@ fun NewIdentityScreen( var bio by remember { mutableStateOf("") } var picture by remember { mutableStateOf(null) } - val launcher = - rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri: Uri? -> - picture = uri - } + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + picture = uri + } Scaffold( containerColor = MaterialTheme.colorScheme.surfaceContainer, @@ -224,7 +225,7 @@ fun NewIdentityScreen( }, modifier = Modifier .fillMaxWidth() - .height(ButtonDefaults.LargeContainerHeight), + .height(ButtonDefaults.MediumContainerHeight), enabled = name.isNotBlank() && !isLoading, ) { if (isLoading) { @@ -232,7 +233,7 @@ fun NewIdentityScreen( } else { Text( text = "Continue", - style = MaterialTheme.typography.titleLargeEmphasized, + style = MaterialTheme.typography.titleMediumEmphasized, ) } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 28fbe0f..bc58a27 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -569,7 +569,6 @@ class Nostr { 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), - RelayUrl.parse("wss://relay.damus.io") to listOf(filter), ) ) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index adb1846..0cbb0be 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -295,6 +295,25 @@ class NostrViewModel( } } + 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")) { -- 2.49.1 From 39d899b249eb6853240d921828e752fc9989320e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 21 May 2026 11:48:21 +0700 Subject: [PATCH 40/43] add loading state to home screen --- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 11 ++++++++++- .../commonMain/kotlin/su/reya/coop/NostrViewModel.kt | 7 +++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index e7bc625..89116fb 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -24,6 +24,7 @@ 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 @@ -93,6 +94,7 @@ fun HomeScreen( 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() @@ -212,7 +214,14 @@ fun HomeScreen( ) } ) { - if (chatRooms.isEmpty()) { + if (!isPartialProcessedGiftWrap && chatRooms.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + LoadingIndicator() + } + } else if (chatRooms.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 0cbb0be..60a31fc 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -48,6 +48,9 @@ class NostrViewModel( private val _contactList = MutableStateFlow>(emptySet()) val contactList = _contactList.asStateFlow() + private val _isPartialProcessedGiftWrap = MutableStateFlow(false) + val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow() + private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) val newEvents = _newEvents.asSharedFlow() @@ -99,6 +102,10 @@ class NostrViewModel( }, onSubscriptionClose = { getChatRooms() + + if (!_isPartialProcessedGiftWrap.value) { + _isPartialProcessedGiftWrap.value = true + } }, onNewMessage = { event -> viewModelScope.launch { -- 2.49.1 From a7215c72836c8201258711a1da381392e577fa54 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 21 May 2026 18:25:04 +0700 Subject: [PATCH 41/43] add verify msg relay --- .../androidMain/kotlin/su/reya/coop/App.kt | 84 ++++++++++++++++++- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 12 ++- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 39 ++++++++- .../kotlin/su/reya/coop/NostrViewModel.kt | 37 ++++++++ 4 files changed, 164 insertions(+), 8 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index a5f5223..9d505f3 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -1,27 +1,49 @@ package su.reya.coop import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.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.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.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 @@ -43,11 +65,12 @@ val LocalNavController = staticCompositionLocalOf { error("No NavController provided") } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun App() { val context = LocalContext.current val navController = rememberNavController() + val scope = rememberCoroutineScope() val darkMode = isSystemInDarkTheme() // Snackbar @@ -82,6 +105,8 @@ fun App() { LocalNavController provides navController, ) { 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 @@ -95,6 +120,61 @@ fun App() { // 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 = { }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ) { + 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 @@ -159,4 +239,4 @@ fun App() { } } } -} \ No newline at end of file +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 89116fb..a05de92 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -60,6 +60,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.toClipEntry +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 @@ -214,7 +215,7 @@ fun HomeScreen( ) } ) { - if (!isPartialProcessedGiftWrap && chatRooms.isEmpty()) { + if (!isPartialProcessedGiftWrap) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -226,10 +227,15 @@ fun HomeScreen( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { Text( text = "No chats yet", - style = MaterialTheme.typography.titleLargeEmphasized, + style = MaterialTheme.typography.titleLargeEmphasized.copy( + fontWeight = FontWeight.SemiBold + ), color = MaterialTheme.colorScheme.onSurface ) Text( diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index bc58a27..4dc2d8e 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -348,7 +348,7 @@ class Nostr { is RelayMessageEnum.EndOfStoredEvents -> { val subscriptionId = message.subscriptionId - if (subscriptionId == "messages") { + if (subscriptionId == "all-gift-wraps" || subscriptionId == "newest-gift-wraps") { onSubscriptionClose() } } @@ -471,7 +471,7 @@ class Nostr { return relayList } - private suspend fun getMsgRelayList(): List { + suspend fun getDefaultMsgRelayList(): List { // Construct a list of messaging relays val msgRelayList = listOf( RelayUrl.parse("wss://relay.0xchat.com"), @@ -500,7 +500,7 @@ class Nostr { ) // Send messaging relay list event - val msgRelayList = getMsgRelayList() + val msgRelayList = getDefaultMsgRelayList() val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).signWithKeys(keys) client?.sendEvent( @@ -578,6 +578,39 @@ class Nostr { } } + 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 getChatRooms(): Set? { try { val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in") diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 60a31fc..aeb2087 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -7,6 +7,7 @@ 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 @@ -51,6 +52,9 @@ class NostrViewModel( 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() @@ -69,6 +73,7 @@ class NostrViewModel( startMetadataBatchHandler() getCacheMetadata() login() + observeSignerAndCheckRelays() } override fun onCleared() { @@ -202,6 +207,25 @@ class NostrViewModel( } } + 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 { @@ -234,6 +258,10 @@ class NostrViewModel( } } + fun dismissRelayWarning() { + _isRelayListEmpty.value = false + } + private suspend fun getOrInitAppKeys(): Keys { val secret = secretStore.get("app_keys") @@ -349,6 +377,15 @@ class NostrViewModel( } } + suspend fun useDefaultMsgRelayList() { + try { + val defaultRelays = nostr.getDefaultMsgRelayList() + nostr.setMsgRelays(defaultRelays) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + 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") -- 2.49.1 From e775d799ea305f016d9c4f95777cc0da3a6d8e2a Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 22 May 2026 09:12:40 +0700 Subject: [PATCH 42/43] add my qr screen --- composeApp/build.gradle.kts | 1 + .../composeResources/drawable/ic_qr.xml | 9 +++ .../androidMain/kotlin/su/reya/coop/App.kt | 6 ++ .../kotlin/su/reya/coop/Navigation.kt | 3 + .../kotlin/su/reya/coop/screens/HomeScreen.kt | 21 +++++- .../kotlin/su/reya/coop/screens/MyQrScreen.kt | 68 +++++++++++++++++++ .../kotlin/su/reya/coop/screens/ScanScreen.kt | 3 +- 7 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_qr.xml create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index dc58cbf..954b656 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -28,6 +28,7 @@ kotlin { 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/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/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 9d505f3..3dc0cc9 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -48,6 +48,7 @@ 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 @@ -236,6 +237,11 @@ fun App() { onBack = { navController.popBackStack() }, ) } + composable { backStackEntry -> + MyQrScreen( + onBack = { navController.popBackStack() }, + ) + } } } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt index 676eb7d..15bf958 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt @@ -23,4 +23,7 @@ sealed interface Screen { @Serializable data object Scan : Screen + + @Serializable + data object MyQr : Screen } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index a05de92..4f26a3e 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -19,6 +19,7 @@ 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 @@ -64,6 +65,7 @@ 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 @@ -304,8 +306,8 @@ fun HomeScreen( ) } Spacer(modifier = Modifier.size(8.dp)) - Box( - contentAlignment = Alignment.Center + Row( + verticalAlignment = Alignment.CenterVertically, ) { OutlinedButton( onClick = { @@ -320,6 +322,21 @@ fun HomeScreen( ) { 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)) 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/ScanScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt index 1386c22..b351b02 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt @@ -52,7 +52,8 @@ fun ScanScreen( TopAppBar( title = { Text( - text = "Scan QR", style = MaterialTheme.typography.titleMediumEmphasized + text = "Scan QR", + style = MaterialTheme.typography.titleMediumEmphasized ) }, navigationIcon = { -- 2.49.1 From 9e2db0dbb066d65361b487392b4d4302ee49f192 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 22 May 2026 16:48:57 +0700 Subject: [PATCH 43/43] add relay screen --- .../androidMain/kotlin/su/reya/coop/App.kt | 8 +- .../kotlin/su/reya/coop/Navigation.kt | 3 + .../kotlin/su/reya/coop/screens/HomeScreen.kt | 35 +-- .../su/reya/coop/screens/RelayScreen.kt | 236 ++++++++++++++++++ .../commonMain/kotlin/su/reya/coop/Nostr.kt | 13 + .../kotlin/su/reya/coop/NostrViewModel.kt | 19 ++ 6 files changed, 297 insertions(+), 17 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 3dc0cc9..8185f79 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -52,6 +52,7 @@ 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 val LocalNostrViewModel = staticCompositionLocalOf { @@ -124,7 +125,7 @@ fun App() { // Show the relay setup dialog if the msg relay list is empty if (isRelayListEmpty) { ModalBottomSheet( - onDismissRequest = { }, + onDismissRequest = { viewModel.dismissRelayWarning() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surfaceContainer, ) { @@ -242,6 +243,11 @@ fun App() { onBack = { navController.popBackStack() }, ) } + composable { backStackEntry -> + RelayScreen( + onBack = { navController.popBackStack() }, + ) + } } } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt index 15bf958..1b4ddb4 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt @@ -26,4 +26,7 @@ sealed interface Screen { @Serializable data object MyQr : Screen + + @Serializable + data object Relay : Screen } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 4f26a3e..c6ec696 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -1,6 +1,5 @@ package su.reya.coop.screens -import android.content.ClipData import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -60,7 +59,6 @@ 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.platform.toClipEntry import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import coop.composeapp.generated.resources.Res @@ -269,11 +267,20 @@ fun HomeScreen( ) { 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) @@ -311,13 +318,7 @@ fun HomeScreen( ) { OutlinedButton( onClick = { - scope.launch { - if (pubkey != null) { - val text = pubkey.toBech32(); - val entry = ClipData.newPlainText("text", text) - clipboard.setClipEntry(entry.toClipEntry()) - } - } + dismissAndRun { navController.navigate(Screen.MyQr) } }, ) { Text(text = shortPubkey) @@ -340,7 +341,7 @@ fun HomeScreen( } } Spacer(modifier = Modifier.size(16.dp)) - BottomMenuList() + BottomMenuList(onDismiss = dismissAndRun) } } } @@ -394,15 +395,17 @@ fun ChatRoom(room: Room, onClick: () -> Unit) { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun BottomMenuList() { +fun BottomMenuList( + onDismiss: (suspend () -> Unit) -> Unit +) { + val navController = LocalNavController.current val viewModel = LocalNostrViewModel.current val defaultMenuList = listOf( - "Messaging Relays" to { }, - "Spam Filter" to { }, + "Relay Management" to { navController.navigate(Screen.Relay) }, + "Spams & Blocks" to { }, "Contacts" to { }, - "Settings" to { }, - "About" to { } + "Settings" to { } ) Column( @@ -415,7 +418,7 @@ fun BottomMenuList() { ) { defaultMenuList.forEachIndexed { index, (title, action) -> SegmentedListItem( - onClick = { action() }, + onClick = { onDismiss { action() } }, shapes = ListItemDefaults.segmentedShapes( index = index, count = defaultMenuList.size 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/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 4dc2d8e..54b5a70 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -51,6 +51,7 @@ 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 @@ -611,6 +612,18 @@ class Nostr { } } + 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") diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index aeb2087..3699031 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -25,6 +25,7 @@ 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 @@ -386,6 +387,24 @@ class NostrViewModel( } } + 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") -- 2.49.1