update nostr sdk
This commit is contained in:
@@ -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<PublicKey> = 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<RelayUrl, List<Filter>>()
|
||||
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<PublicKey>) {
|
||||
@@ -411,7 +410,7 @@ class Nostr {
|
||||
|
||||
suspend fun getChatRooms(): Set<Room>? {
|
||||
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<PublicKey>): List<Event> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Metadata?> {
|
||||
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<Event> {
|
||||
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(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun PublicKey.short(): String {
|
||||
val bech32 = toBech32()
|
||||
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
|
||||
}
|
||||
|
||||
55
shared/src/commonMain/kotlin/su/reya/coop/Signer.kt
Normal file
55
shared/src/commonMain/kotlin/su/reya/coop/Signer.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user