update nostr sdk
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
agp = "9.2.0"
|
||||
agp = "9.2.1"
|
||||
android-compileSdk = "36"
|
||||
android-minSdk = "24"
|
||||
android-targetSdk = "36"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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