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("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)

View File

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

View File

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

View File

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

View File

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

View File

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

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