From a4bd1c29001205cc313d11a68d6c4ee4359ab35f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 10 May 2026 20:35:19 +0700 Subject: [PATCH] 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)