update nostr class
This commit is contained in:
@@ -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.2.1")
|
||||
implementation("su.reya:nostr-sdk-kmp:0.2.2")
|
||||
implementation("com.squareup.okio:okio:3.16.2")
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.websockets)
|
||||
|
||||
@@ -2,6 +2,10 @@ package su.reya.coop
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import rust.nostr.sdk.AckPolicy
|
||||
import rust.nostr.sdk.AsyncNostrSigner
|
||||
import rust.nostr.sdk.Client
|
||||
@@ -32,10 +36,12 @@ import rust.nostr.sdk.SendEventTarget
|
||||
import rust.nostr.sdk.SleepWhenIdle
|
||||
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
||||
import rust.nostr.sdk.Tag
|
||||
import rust.nostr.sdk.TagKind
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import rust.nostr.sdk.UnwrappedGift
|
||||
import rust.nostr.sdk.initLogger
|
||||
import rust.nostr.sdk.makePrivateMsgAsync
|
||||
import rust.nostr.sdk.nip17ExtractRelayList
|
||||
import kotlin.time.Duration
|
||||
|
||||
@@ -46,6 +52,8 @@ class Nostr {
|
||||
private set
|
||||
var deviceSigner: AsyncNostrSigner? = null
|
||||
private set
|
||||
var msgRelayList: Map<PublicKey, List<RelayUrl>> = emptyMap()
|
||||
private set
|
||||
var contactList: List<PublicKey> = emptyList()
|
||||
private set
|
||||
|
||||
@@ -94,7 +102,8 @@ class Nostr {
|
||||
client?.shutdown()
|
||||
}
|
||||
|
||||
fun exit() {
|
||||
suspend fun exit() {
|
||||
signer.switch(Keys.generate())
|
||||
deviceSigner = null
|
||||
contactList = emptyList()
|
||||
}
|
||||
@@ -164,17 +173,23 @@ class Nostr {
|
||||
|
||||
client?.subscribe(
|
||||
target = ReqTarget.manual(target),
|
||||
id = "user-messages"
|
||||
id = "messages"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleNotifications(onMetadataUpdate: (PublicKey, Metadata) -> Unit) {
|
||||
suspend fun handleNotifications(
|
||||
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
|
||||
onEose: () -> Unit,
|
||||
onNewMessage: (Event) -> Unit
|
||||
) = coroutineScope {
|
||||
val now = Timestamp.now()
|
||||
val processedEvent = mutableSetOf<EventId>()
|
||||
val notifications = client?.notifications() ?: return
|
||||
val notifications = client?.notifications() ?: return@coroutineScope
|
||||
|
||||
var eoseTrackerJob: Job? = null
|
||||
|
||||
while (true) {
|
||||
val notification = notifications.next() ?: continue
|
||||
@@ -182,7 +197,7 @@ class Nostr {
|
||||
when (notification) {
|
||||
is ClientNotification.Message -> {
|
||||
val relayUrl = notification.relayUrl
|
||||
|
||||
|
||||
when (val message = notification.message.asEnum()) {
|
||||
is RelayMessageEnum.EventMsg -> {
|
||||
val event = message.event
|
||||
@@ -204,12 +219,30 @@ class Nostr {
|
||||
if (isSignedByUser(event = event)) {
|
||||
getUserMessages(msgRelayList = event)
|
||||
}
|
||||
// Cache the relay list for future use
|
||||
setMsgRelay(pubkey = event.author(), event = event)
|
||||
}
|
||||
|
||||
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
|
||||
try {
|
||||
val rumor = extractRumor(event)
|
||||
// TODO: Handle rumor
|
||||
|
||||
// Logic to notify UI after processing
|
||||
// Cancel previous tracker if it exists
|
||||
eoseTrackerJob?.cancel()
|
||||
// Start a new tracker
|
||||
eoseTrackerJob = launch {
|
||||
delay(10000) // Wait for 10 seconds
|
||||
onEose()
|
||||
}
|
||||
|
||||
// Handle new message
|
||||
rumor?.createdAt()?.asSecs()?.let {
|
||||
if (it >= now.asSecs()) {
|
||||
// TODO: only send unsigned event
|
||||
onNewMessage(rumor.signWithKeys(Keys.generate()))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Failed to extract rumor: $e")
|
||||
}
|
||||
@@ -218,7 +251,10 @@ class Nostr {
|
||||
|
||||
is RelayMessageEnum.EndOfStoredEvents -> {
|
||||
val subscriptionId = message.subscriptionId
|
||||
// TODO: Handle end of stored events
|
||||
|
||||
if (subscriptionId == "messages") {
|
||||
onEose()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -238,6 +274,11 @@ class Nostr {
|
||||
}
|
||||
}
|
||||
|
||||
private fun setMsgRelay(pubkey: PublicKey, event: Event) {
|
||||
val relays = nip17ExtractRelayList(event)
|
||||
msgRelayList = msgRelayList + (pubkey to relays)
|
||||
}
|
||||
|
||||
private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
|
||||
try {
|
||||
val filter = Filter().identifier(giftId.toBech32())
|
||||
@@ -245,16 +286,18 @@ class Nostr {
|
||||
|
||||
return event?.content()?.let { UnsignedEvent.fromJson(it) }
|
||||
} catch (e: Exception) {
|
||||
println("Failed to get cached rumor: ${e.message}")
|
||||
return null
|
||||
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
|
||||
if (rumor.id() == null) return
|
||||
|
||||
try {
|
||||
val rngKeys = Keys.generate()
|
||||
|
||||
// Ensure the rumor ID is set
|
||||
val rumor = rumor.ensureId()
|
||||
|
||||
// Construct a reference event
|
||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA);
|
||||
val tags = listOf(Tag.identifier(giftId.toBech32()), Tag.event(rumor.id()!!))
|
||||
val event = EventBuilder(kind, rumor.asJson()).tags(tags).signWithKeys(rngKeys)
|
||||
@@ -444,7 +487,10 @@ class Nostr {
|
||||
val room = Room.new(rumor = event, userPubkey = userPubkey)
|
||||
|
||||
// Check if the room already exists
|
||||
if (rooms.contains(room)) return@forEach
|
||||
if (rooms.contains(room)) {
|
||||
room.setCreatedAt(room.createdAt)
|
||||
room.setLastMessage(room.lastMessage)
|
||||
}
|
||||
|
||||
val filter =
|
||||
Filter().kind(kind).author(userPubkey).pubkeys(room.members.toList());
|
||||
@@ -473,18 +519,95 @@ class Nostr {
|
||||
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 sendFilter = Filter().kind(kind).author(userPubkey).pubkeys(members)
|
||||
val sendEvents = client?.database()?.query(sendFilter)
|
||||
|
||||
val recvFilter = Filter().kind(kind).authors(members).pubkey(userPubkey)
|
||||
val recvEvents = client?.database()?.query(recvFilter)
|
||||
val events = sendEvents?.merge(recvEvents!!)?.toVec()
|
||||
|
||||
// Merge the events
|
||||
val events = sendEvents
|
||||
?.merge(recvEvents!!)
|
||||
?.toVec()
|
||||
?.sortedByDescending { it.createdAt().asSecs() }
|
||||
|
||||
return events ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun chatRoomConnect(members: List<PublicKey>) {
|
||||
try {
|
||||
members.forEach { member ->
|
||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||
val filter = Filter().kind(kind).author(member).limit(1u)
|
||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||
|
||||
client?.subscribe(
|
||||
target = ReqTarget.auto(listOf(filter)),
|
||||
closeOn = opts
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to connect to chat room: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendMessage(
|
||||
to: List<PublicKey>,
|
||||
content: String,
|
||||
subject: String? = null,
|
||||
replies: List<EventId> = emptyList()
|
||||
) {
|
||||
try {
|
||||
val currentUser =
|
||||
signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
val tags = mutableListOf<Tag>()
|
||||
|
||||
// Add a subject tag if provided
|
||||
if (subject != null) {
|
||||
tags.add(Tag.custom(TagKind.Subject, listOf(subject)))
|
||||
}
|
||||
|
||||
// Add event tags for replies
|
||||
if (replies.isNotEmpty()) {
|
||||
replies.forEach { replyId ->
|
||||
tags.add(Tag.event(replyId))
|
||||
}
|
||||
}
|
||||
|
||||
// Add public key tags for each recipient
|
||||
to.forEach { pubkey ->
|
||||
if (pubkey != currentUser) {
|
||||
tags.add(Tag.publicKey(pubkey))
|
||||
}
|
||||
}
|
||||
|
||||
for (receiver in to.plus(currentUser)) {
|
||||
// Construct the gift wrap event
|
||||
val event = makePrivateMsgAsync(
|
||||
signer = signer,
|
||||
receiver = receiver,
|
||||
message = content,
|
||||
rumorExtraTags = tags
|
||||
)
|
||||
|
||||
println("Sending message to: ${receiver.toBech32()}")
|
||||
|
||||
// Send the event to receiver's NIP-17 relays
|
||||
client?.sendEvent(
|
||||
event = event,
|
||||
target = SendEventTarget.toNip17(),
|
||||
ackPolicy = AckPolicy.none(),
|
||||
authenticationTimeout = Duration.parse("2s")
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to send message: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -16,6 +18,7 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import rust.nostr.sdk.Event
|
||||
import rust.nostr.sdk.EventId
|
||||
import rust.nostr.sdk.Keys
|
||||
import rust.nostr.sdk.Metadata
|
||||
import rust.nostr.sdk.NostrConnect
|
||||
@@ -39,6 +42,9 @@ class NostrViewModel(
|
||||
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
||||
val chatRooms = _chatRooms.asStateFlow()
|
||||
|
||||
private val _newEvents = MutableSharedFlow<Event>(extraBufferCapacity = 100)
|
||||
val newEvents = _newEvents.asSharedFlow()
|
||||
|
||||
private val _errorEvents = Channel<String>(Channel.BUFFERED)
|
||||
val errorEvents = _errorEvents.receiveAsFlow()
|
||||
|
||||
@@ -123,9 +129,19 @@ class NostrViewModel(
|
||||
|
||||
fun startNotificationHandler() {
|
||||
viewModelScope.launch {
|
||||
nostr.handleNotifications { pubkey, metadata ->
|
||||
updateMetadata(pubkey, metadata)
|
||||
}
|
||||
nostr.handleNotifications(
|
||||
onMetadataUpdate = { pubkey, metadata ->
|
||||
updateMetadata(pubkey, metadata)
|
||||
},
|
||||
onEose = {
|
||||
getChatRooms()
|
||||
},
|
||||
onNewMessage = { event ->
|
||||
viewModelScope.launch {
|
||||
_newEvents.emit(event)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +315,35 @@ class NostrViewModel(
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
fun chatRoomConnect(roomId: Long) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val room = getChatRoom(roomId)
|
||||
val members = room.members
|
||||
|
||||
nostr.chatRoomConnect(members.toList())
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val room = getChatRoom(roomId)
|
||||
nostr.sendMessage(
|
||||
to = room.members.toList(),
|
||||
content = message,
|
||||
subject = room.subject,
|
||||
replies = replies
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
// Ensure all relays are disconnect
|
||||
|
||||
@@ -77,6 +77,10 @@ data class Room(
|
||||
return this.copy(subject = subject)
|
||||
}
|
||||
|
||||
fun setLastMessage(message: String?): Room {
|
||||
return this.copy(lastMessage = message)
|
||||
}
|
||||
|
||||
fun isGroup(): Boolean {
|
||||
return members.size > 1
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user