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") } } }