chore: merge the develop branch into master #1

Merged
reya merged 43 commits from develop into master 2026-05-23 00:50:13 +00:00
8 changed files with 268 additions and 95 deletions
Showing only changes of commit a4bd1c2900 - Show all commits

View File

@@ -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)

View File

@@ -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) {
)
}
}
Spacer(modifier = Modifier.size(8.dp))
Box(
contentAlignment = Alignment.Center
) {
Text(
text = userName,
style = MaterialTheme.typography.titleLargeEmphasized,
)
HorizontalDivider()
Button(
onClick = { viewModel.logout() },
content = { Text("Logout") }
)
}
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) },
)
}
}
}

View File

@@ -1,5 +1,5 @@
[versions]
agp = "9.2.0"
agp = "9.2.1"
android-compileSdk = "36"
android-minSdk = "24"
android-targetSdk = "36"

View File

@@ -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)

View File

@@ -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()
}
}

View File

@@ -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
@@ -291,3 +304,8 @@ class NostrViewModel(
}
}
}
fun PublicKey.short(): String {
val bech32 = toBech32()
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
}

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

View File

@@ -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)