chore: merge the develop branch into master #1
@@ -25,7 +25,7 @@ kotlin {
|
|||||||
implementation("org.jetbrains.compose.material3:material3:1.11.0-alpha07")
|
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-compose:3.4.0")
|
||||||
implementation("io.coil-kt.coil3:coil-network-okhttp: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 {
|
commonMain.dependencies {
|
||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
|
|||||||
@@ -1,61 +1,82 @@
|
|||||||
package su.reya.coop.screens
|
package su.reya.coop.screens
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.ListItemDefaults
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialShapes
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SegmentedListItem
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.material3.toShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.layout.ContentScale
|
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 androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_avatar
|
import coop.composeapp.generated.resources.ic_avatar
|
||||||
import coop.composeapp.generated.resources.ic_search
|
import coop.composeapp.generated.resources.ic_search
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.short
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(onOpenChat: (Long) -> Unit) {
|
fun HomeScreen(onOpenChat: (Long) -> Unit) {
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val viewModel = LocalNostrViewModel.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 chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList())
|
||||||
|
|
||||||
val sheetState = rememberModalBottomSheetState()
|
val sheetState = rememberModalBottomSheetState()
|
||||||
var showBottomSheet by remember { mutableStateOf(false) }
|
var showBottomSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -141,27 +162,33 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) {
|
|||||||
onDismissRequest = { showBottomSheet = false },
|
onDismissRequest = { showBottomSheet = false },
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
) {
|
) {
|
||||||
|
val pubkey = viewModel.currentUser()
|
||||||
|
val shortPubkey = pubkey?.short() ?: "Not available"
|
||||||
val userName =
|
val userName =
|
||||||
userProfile?.asRecord()?.displayName
|
userProfile?.asRecord()?.displayName
|
||||||
?: userProfile?.asRecord()?.name
|
?: userProfile?.asRecord()?.name
|
||||||
?: "No name"
|
?: "No name"
|
||||||
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(
|
||||||
ListItem(
|
modifier = Modifier
|
||||||
headlineContent = {
|
.padding(16.dp)
|
||||||
Text(
|
.fillMaxWidth(),
|
||||||
text = userName,
|
) {
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized
|
Column(
|
||||||
)
|
modifier = Modifier.fillMaxWidth(),
|
||||||
},
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
leadingContent = {
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(84.dp)
|
||||||
|
.clip(MaterialShapes.Cookie9Sided.toShape()),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
if (userProfile?.asRecord()?.picture != null) {
|
if (userProfile?.asRecord()?.picture != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = userProfile?.asRecord()?.picture,
|
model = userProfile?.asRecord()?.picture,
|
||||||
contentDescription = "User Avatar",
|
contentDescription = "User Avatar",
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.size(32.dp)
|
|
||||||
.clip(CircleShape),
|
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -171,12 +198,36 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
HorizontalDivider()
|
Box(
|
||||||
Button(
|
contentAlignment = Alignment.Center
|
||||||
onClick = { viewModel.logout() },
|
) {
|
||||||
content = { Text("Logout") }
|
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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "9.2.0"
|
agp = "9.2.1"
|
||||||
android-compileSdk = "36"
|
android-compileSdk = "36"
|
||||||
android-minSdk = "24"
|
android-minSdk = "24"
|
||||||
android-targetSdk = "36"
|
android-targetSdk = "36"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ kotlin {
|
|||||||
implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
|
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-coroutines-core:1.10.2")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
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("com.squareup.okio:okio:3.16.2")
|
||||||
implementation(libs.ktor.client.core)
|
implementation(libs.ktor.client.core)
|
||||||
implementation(libs.ktor.client.websockets)
|
implementation(libs.ktor.client.websockets)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package su.reya.coop
|
|||||||
|
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.plugins.websocket.WebSockets
|
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.Client
|
||||||
import rust.nostr.sdk.ClientBuilder
|
import rust.nostr.sdk.ClientBuilder
|
||||||
import rust.nostr.sdk.ClientNotification
|
import rust.nostr.sdk.ClientNotification
|
||||||
@@ -17,10 +19,8 @@ import rust.nostr.sdk.KindStandard
|
|||||||
import rust.nostr.sdk.LogLevel
|
import rust.nostr.sdk.LogLevel
|
||||||
import rust.nostr.sdk.Metadata
|
import rust.nostr.sdk.Metadata
|
||||||
import rust.nostr.sdk.MetadataRecord
|
import rust.nostr.sdk.MetadataRecord
|
||||||
import rust.nostr.sdk.NostrConnect
|
|
||||||
import rust.nostr.sdk.NostrDatabase
|
import rust.nostr.sdk.NostrDatabase
|
||||||
import rust.nostr.sdk.NostrGossip
|
import rust.nostr.sdk.NostrGossip
|
||||||
import rust.nostr.sdk.NostrSigner
|
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.RelayCapabilities
|
import rust.nostr.sdk.RelayCapabilities
|
||||||
import rust.nostr.sdk.RelayMessageEnum
|
import rust.nostr.sdk.RelayMessageEnum
|
||||||
@@ -28,24 +28,23 @@ import rust.nostr.sdk.RelayMetadata
|
|||||||
import rust.nostr.sdk.RelayUrl
|
import rust.nostr.sdk.RelayUrl
|
||||||
import rust.nostr.sdk.ReqExitPolicy
|
import rust.nostr.sdk.ReqExitPolicy
|
||||||
import rust.nostr.sdk.ReqTarget
|
import rust.nostr.sdk.ReqTarget
|
||||||
|
import rust.nostr.sdk.SendEventTarget
|
||||||
import rust.nostr.sdk.SleepWhenIdle
|
import rust.nostr.sdk.SleepWhenIdle
|
||||||
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
||||||
import rust.nostr.sdk.Tag
|
import rust.nostr.sdk.Tag
|
||||||
import rust.nostr.sdk.Timestamp
|
import rust.nostr.sdk.Timestamp
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import rust.nostr.sdk.UnwrappedGift
|
import rust.nostr.sdk.UnwrappedGift
|
||||||
import rust.nostr.sdk.extractMessagingRelayList
|
|
||||||
import rust.nostr.sdk.initLogger
|
import rust.nostr.sdk.initLogger
|
||||||
|
import rust.nostr.sdk.nip17ExtractRelayList
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
class Nostr {
|
class Nostr {
|
||||||
var client: Client? = null
|
var client: Client? = null
|
||||||
private set
|
private set
|
||||||
var signer: NostrSigner? = null
|
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
||||||
private set
|
private set
|
||||||
var deviceSigner: NostrSigner? = null
|
var deviceSigner: AsyncNostrSigner? = null
|
||||||
private set
|
|
||||||
var userPubkey: PublicKey? = null
|
|
||||||
private set
|
private set
|
||||||
var contactList: List<PublicKey> = emptyList()
|
var contactList: List<PublicKey> = emptyList()
|
||||||
private set
|
private set
|
||||||
@@ -64,12 +63,13 @@ class Nostr {
|
|||||||
|
|
||||||
client =
|
client =
|
||||||
ClientBuilder()
|
ClientBuilder()
|
||||||
|
.signer(signer)
|
||||||
.websocketTransport(CoopWebSocketClient(httpClient))
|
.websocketTransport(CoopWebSocketClient(httpClient))
|
||||||
.database(lmdb)
|
.database(lmdb)
|
||||||
.gossip(gossip)
|
.gossip(gossip)
|
||||||
.gossipConfig(GossipConfig().noBackgroundRefresh())
|
.gossipConfig(GossipConfig().noBackgroundRefresh())
|
||||||
.verifySubscriptions(false)
|
.verifySubscriptions(false)
|
||||||
.automaticAuthentication(false)
|
.automaticAuthentication(true)
|
||||||
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
|
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ class Nostr {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Connect to all bootstrap relays and wait for all connections to be established
|
// 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) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e)
|
throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -95,39 +95,23 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun exit() {
|
fun exit() {
|
||||||
signer = null
|
|
||||||
deviceSigner = null
|
deviceSigner = null
|
||||||
userPubkey = null
|
|
||||||
contactList = emptyList()
|
contactList = emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setKeySigner(keys: Keys) {
|
suspend fun setSigner(keys: AsyncNostrSigner) {
|
||||||
try {
|
try {
|
||||||
signer = NostrSigner.keys(keys)
|
signer.switch(keys)
|
||||||
userPubkey = signer?.getPublicKey()
|
|
||||||
|
|
||||||
// Fetch metadata for current user
|
// Fetch metadata for current user
|
||||||
getUserMetadata()
|
getUserMetadata()
|
||||||
} catch (e: Exception) {
|
} 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) {
|
fun isSignedByUser(event: Event): Boolean {
|
||||||
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 {
|
|
||||||
return try {
|
return try {
|
||||||
signer?.getPublicKey()?.toBech32() == event.author().toBech32()
|
signer.currentUser == event.author()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("Failed to check if event is signed by user: ${e.message}")
|
println("Failed to check if event is signed by user: ${e.message}")
|
||||||
false
|
false
|
||||||
@@ -135,22 +119,20 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getUserMetadata() {
|
suspend fun getUserMetadata() {
|
||||||
if (userPubkey == null) return
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
val author = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
// Get the latest metadata event
|
// Get the latest metadata event
|
||||||
val metadataFilter =
|
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
|
// Get the latest contact list event
|
||||||
val contactFilter =
|
val contactFilter =
|
||||||
Filter().author(userPubkey!!).limit(1u)
|
Filter().kind(Kind.fromStd(KindStandard.CONTACT_LIST)).author(author).limit(1u)
|
||||||
.kind(Kind.fromStd(KindStandard.CONTACT_LIST))
|
|
||||||
|
|
||||||
// Get the latest messaging relay list event
|
// Get the latest messaging relay list event
|
||||||
val msgRelayFilter =
|
val msgRelayFilter =
|
||||||
Filter().author(userPubkey!!).limit(1u)
|
Filter().kind(Kind.fromStd(KindStandard.INBOX_RELAYS)).author(author).limit(1u)
|
||||||
.kind(Kind.fromStd(KindStandard.INBOX_RELAYS))
|
|
||||||
|
|
||||||
// Construct a target that includes all filters
|
// Construct a target that includes all filters
|
||||||
val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter))
|
val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter))
|
||||||
@@ -164,17 +146,17 @@ class Nostr {
|
|||||||
|
|
||||||
suspend fun getUserMessages(msgRelayList: Event) {
|
suspend fun getUserMessages(msgRelayList: Event) {
|
||||||
try {
|
try {
|
||||||
val userPubkey = signer?.getPublicKey() ?: return
|
val author = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||||
val relays = extractMessagingRelayList(msgRelayList)
|
val relays = nip17ExtractRelayList(msgRelayList)
|
||||||
|
|
||||||
// Ensure relay connections
|
// Ensure relay connections
|
||||||
relays.forEach { relay ->
|
relays.forEach { relay ->
|
||||||
client?.addRelay(relay, RelayCapabilities.none())
|
client?.addRelay(relay)
|
||||||
client?.connectRelay(relay)
|
client?.connectRelay(relay)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct a filter for gift wrap events
|
// 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>>()
|
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||||
relays.forEach { relay ->
|
relays.forEach { relay ->
|
||||||
target[relay] = listOf(filter)
|
target[relay] = listOf(filter)
|
||||||
@@ -182,12 +164,10 @@ class Nostr {
|
|||||||
|
|
||||||
client?.subscribe(
|
client?.subscribe(
|
||||||
target = ReqTarget.manual(target),
|
target = ReqTarget.manual(target),
|
||||||
id = "user-messages",
|
id = "user-messages"
|
||||||
closeOn = null
|
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
|
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,6 +253,7 @@ class Nostr {
|
|||||||
|
|
||||||
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
|
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
|
||||||
if (rumor.id() == null) return
|
if (rumor.id() == null) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val rngKeys = Keys.generate()
|
val rngKeys = Keys.generate()
|
||||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA);
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA);
|
||||||
@@ -287,8 +268,6 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private 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
|
// Check if the rumor is already cached
|
||||||
val cachedRumor = getCachedRumor(event.id())
|
val cachedRumor = getCachedRumor(event.id())
|
||||||
if (cachedRumor != null) return cachedRumor
|
if (cachedRumor != null) return cachedRumor
|
||||||
@@ -301,7 +280,7 @@ class Nostr {
|
|||||||
for (signer in signers) {
|
for (signer in signers) {
|
||||||
try {
|
try {
|
||||||
// TODO: custom unwrapping logic
|
// TODO: custom unwrapping logic
|
||||||
val gift = UnwrappedGift.fromGiftWrap(signer = signer, giftWrap = event)
|
val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event)
|
||||||
val rumor = gift.rumor()
|
val rumor = gift.rumor()
|
||||||
// Save the rumor to the database
|
// Save the rumor to the database
|
||||||
setCachedRumor(event.id(), rumor)
|
setCachedRumor(event.id(), rumor)
|
||||||
@@ -373,27 +352,47 @@ class Nostr {
|
|||||||
// Send relay list event
|
// Send relay list event
|
||||||
val relayList = getDefaultRelayList()
|
val relayList = getDefaultRelayList()
|
||||||
val relayListEvent = EventBuilder.relayList(relayList).signWithKeys(keys);
|
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
|
// Send messaging relay list event
|
||||||
val msgRelayList = getMsgRelayList()
|
val msgRelayList = getMsgRelayList()
|
||||||
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).signWithKeys(keys)
|
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).signWithKeys(keys)
|
||||||
client?.sendEventNoWait(msgRelayListEvent)
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = msgRelayListEvent,
|
||||||
|
target = SendEventTarget.toNip65(),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
)
|
||||||
|
|
||||||
// Send metadata event
|
// Send metadata event
|
||||||
val metadata =
|
val metadata =
|
||||||
Metadata.fromRecord(MetadataRecord(name = name, about = bio, picture = picture))
|
Metadata.fromRecord(MetadataRecord(name = name, about = bio, picture = picture))
|
||||||
val metadataEvent = EventBuilder.metadata(metadata).signWithKeys(keys)
|
val metadataEvent = EventBuilder.metadata(metadata).signWithKeys(keys)
|
||||||
client?.sendEventNoWait(metadataEvent)
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = metadataEvent,
|
||||||
|
target = SendEventTarget.toNip65(),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
)
|
||||||
|
|
||||||
// Send contact list event
|
// Send contact list event
|
||||||
val defaultContact =
|
val defaultContact =
|
||||||
listOf(Contact(publicKey = PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x")))
|
listOf(Contact(publicKey = PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x")))
|
||||||
val contactListEvent = EventBuilder.contactList(defaultContact).signWithKeys(keys)
|
val contactListEvent = EventBuilder.contactList(defaultContact).signWithKeys(keys)
|
||||||
client?.sendEventNoWait(contactListEvent)
|
|
||||||
|
|
||||||
// Set signer
|
client?.sendEvent(
|
||||||
setKeySigner(keys)
|
event = contactListEvent,
|
||||||
|
target = SendEventTarget.toNip65(),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
)
|
||||||
|
|
||||||
|
setSigner(keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
|
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
|
||||||
@@ -411,7 +410,7 @@ class Nostr {
|
|||||||
|
|
||||||
suspend fun getChatRooms(): Set<Room>? {
|
suspend fun getChatRooms(): Set<Room>? {
|
||||||
try {
|
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)
|
val kind = Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE)
|
||||||
|
|
||||||
// Get all events sent by the user
|
// Get all events sent by the user
|
||||||
@@ -458,4 +457,23 @@ class Nostr {
|
|||||||
return null
|
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.withContext
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import rust.nostr.sdk.Event
|
||||||
import rust.nostr.sdk.Keys
|
import rust.nostr.sdk.Keys
|
||||||
import rust.nostr.sdk.Metadata
|
import rust.nostr.sdk.Metadata
|
||||||
import rust.nostr.sdk.NostrConnect
|
import rust.nostr.sdk.NostrConnect
|
||||||
import rust.nostr.sdk.NostrConnectUri
|
import rust.nostr.sdk.NostrConnectUri
|
||||||
import rust.nostr.sdk.NostrSigner
|
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import su.reya.coop.blossom.BlossomClient
|
import su.reya.coop.blossom.BlossomClient
|
||||||
import su.reya.coop.storage.SecretStorage
|
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)) {
|
if (seenPublicKeys.add(pubkey)) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
metadataRequestChannel.send(pubkey)
|
metadataRequestChannel.send(pubkey)
|
||||||
@@ -106,14 +106,10 @@ class NostrViewModel(
|
|||||||
return flow.asStateFlow()
|
return flow.asStateFlow()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateMetadata(pubkey: PublicKey, metadata: Metadata) {
|
private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) {
|
||||||
_metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = 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) {
|
suspend fun initAndConnect(dbPath: String) {
|
||||||
try {
|
try {
|
||||||
// Initialize nostr client
|
// Initialize nostr client
|
||||||
@@ -133,6 +129,10 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun currentUser(): PublicKey? {
|
||||||
|
return nostr.signer.currentUser
|
||||||
|
}
|
||||||
|
|
||||||
fun logout() {
|
fun logout() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_emptySecret.value = true
|
_emptySecret.value = true
|
||||||
@@ -159,14 +159,14 @@ class NostrViewModel(
|
|||||||
// Handle different signer types
|
// Handle different signer types
|
||||||
if (secret.startsWith("nsec1")) {
|
if (secret.startsWith("nsec1")) {
|
||||||
val keys = Keys.parse(secret)
|
val keys = Keys.parse(secret)
|
||||||
nostr.setKeySigner(keys)
|
nostr.setSigner(keys)
|
||||||
} else if (secret.startsWith("bunker://")) {
|
} else if (secret.startsWith("bunker://")) {
|
||||||
try {
|
try {
|
||||||
val appKeys = getOrInitAppKeys()
|
val appKeys = getOrInitAppKeys()
|
||||||
val bunker = NostrConnectUri.parse(secret)
|
val bunker = NostrConnectUri.parse(secret)
|
||||||
val timeout = Duration.parse("50s") // 50 seconds timeout
|
val timeout = Duration.parse("50s") // 50 seconds timeout
|
||||||
val remote = NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null)
|
val remote = NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null)
|
||||||
nostr.setRemoteSigner(remote)
|
nostr.setSigner(remote)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -223,7 +223,7 @@ class NostrViewModel(
|
|||||||
val descriptor = blossom.upload(
|
val descriptor = blossom.upload(
|
||||||
file = picture,
|
file = picture,
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
signer = NostrSigner.keys(keys)
|
signer = keys
|
||||||
)
|
)
|
||||||
|
|
||||||
avatarUrl = descriptor?.url ?: ""
|
avatarUrl = descriptor?.url ?: ""
|
||||||
@@ -247,7 +247,7 @@ class NostrViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (secret.startsWith("nsec1")) {
|
if (secret.startsWith("nsec1")) {
|
||||||
val keys = Keys.parse(secret)
|
val keys = Keys.parse(secret)
|
||||||
nostr.setKeySigner(keys)
|
nostr.setSigner(keys)
|
||||||
secretStore.set("user_signer", secret)
|
secretStore.set("user_signer", secret)
|
||||||
// Set an empty secret state
|
// Set an empty secret state
|
||||||
_emptySecret.value = false
|
_emptySecret.value = false
|
||||||
@@ -258,7 +258,7 @@ class NostrViewModel(
|
|||||||
val timeout = Duration.parse("50s") // 50 seconds timeout
|
val timeout = Duration.parse("50s") // 50 seconds timeout
|
||||||
val remote =
|
val remote =
|
||||||
NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null)
|
NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null)
|
||||||
nostr.setRemoteSigner(remote)
|
nostr.setSigner(remote)
|
||||||
secretStore.set("user_signer", secret)
|
secretStore.set("user_signer", secret)
|
||||||
// Set an empty secret state
|
// Set an empty secret state
|
||||||
_emptySecret.value = false
|
_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() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
// Ensure all relays are disconnect
|
// 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.http.HttpStatusCode
|
||||||
import io.ktor.utils.io.core.toByteArray
|
import io.ktor.utils.io.core.toByteArray
|
||||||
import okio.ByteString.Companion.toByteString
|
import okio.ByteString.Companion.toByteString
|
||||||
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
import rust.nostr.sdk.EventBuilder
|
import rust.nostr.sdk.EventBuilder
|
||||||
import rust.nostr.sdk.NostrSigner
|
|
||||||
import rust.nostr.sdk.Timestamp
|
import rust.nostr.sdk.Timestamp
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
@@ -23,7 +23,7 @@ class BlossomClient(
|
|||||||
suspend fun upload(
|
suspend fun upload(
|
||||||
file: ByteArray,
|
file: ByteArray,
|
||||||
contentType: String? = null,
|
contentType: String? = null,
|
||||||
signer: NostrSigner? = null
|
signer: AsyncNostrSigner? = null
|
||||||
): BlobDescriptor? {
|
): BlobDescriptor? {
|
||||||
val url = "$url/upload"
|
val url = "$url/upload"
|
||||||
val hash = file.toByteString().sha256().hex()
|
val hash = file.toByteString().sha256().hex()
|
||||||
@@ -71,8 +71,11 @@ class BlossomClient(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun buildAuthHeader(signer: NostrSigner, authz: BlossomAuthorization): HeaderValue {
|
suspend fun buildAuthHeader(
|
||||||
val authEvent = EventBuilder.blossomAuth(authz).sign(signer)
|
signer: AsyncNostrSigner,
|
||||||
|
authz: BlossomAuthorization
|
||||||
|
): HeaderValue {
|
||||||
|
val authEvent = EventBuilder.blossomAuth(authz).signAsync(signer)
|
||||||
val encodedAuth = Base64.encode(authEvent.asJson().toByteArray())
|
val encodedAuth = Base64.encode(authEvent.asJson().toByteArray())
|
||||||
val value = "Nostr $encodedAuth"
|
val value = "Nostr $encodedAuth"
|
||||||
return HeaderValue(value)
|
return HeaderValue(value)
|
||||||
|
|||||||
Reference in New Issue
Block a user