update nostr class

This commit is contained in:
2026-05-13 15:37:09 +07:00
parent 428a7ef7af
commit b0fcb05cdf
7 changed files with 266 additions and 33 deletions

View File

@@ -25,7 +25,7 @@ kotlin {
implementation("org.jetbrains.compose.material3:material3:1.11.0-alpha07")
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
implementation("su.reya:nostr-sdk-kmp:0.2.1")
implementation("su.reya:nostr-sdk-kmp:0.2.2")
}
commonMain.dependencies {
implementation(libs.compose.runtime)

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M440,800L440,313L216,537L160,480L480,160L800,480L744,537L520,313L520,800L440,800Z" />
</vector>

View File

@@ -2,12 +2,15 @@ package su.reya.coop.screens
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
@@ -15,8 +18,10 @@ 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.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -31,6 +36,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -39,16 +45,19 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_avatar
import coop.composeapp.generated.resources.ic_send
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Event
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.humanReadable
import su.reya.coop.roomId
import su.reya.coop.shared.displayNameFlow
import su.reya.coop.shared.pictureFlow
@@ -59,18 +68,41 @@ fun ChatScreen(
) {
val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current
val room = viewModel.getChatRoom(id)
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
var text by remember { mutableStateOf("") }
var messages by remember { mutableStateOf<List<Event>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
val messages = remember { mutableStateListOf<Event>() }
fun setLoading(value: Boolean) {
loading = value
}
LaunchedEffect(id) {
loading = true
messages = viewModel.getChatRoomMessages(id)
loading = false
// Start loading spinner
setLoading(true)
// Get messages
val initialMessages = viewModel.getChatRoomMessages(id)
messages.clear()
messages.addAll(initialMessages)
// Get msg relays for each member
viewModel.chatRoomConnect(id)
// Stop loading spinner
setLoading(false)
// Handle new messages
viewModel.newEvents.collect { event ->
if (event.roomId() == id) {
messages.add(0, event)
}
}
}
Scaffold(
@@ -153,7 +185,7 @@ fun ChatScreen(
value = text,
onValueChange = { text = it },
onSend = {
// TODO: Implement send logic
viewModel.sendMessage(id, text)
text = ""
}
)
@@ -190,10 +222,13 @@ fun ChatMessage(
.padding(vertical = 4.dp),
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
Column {
Column(
horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
) {
Text(
text = event.createdAt().humanReadable(),
style = MaterialTheme.typography.labelSmall,
textAlign = if (isMine) TextAlign.End else TextAlign.Start,
)
Spacer(modifier = Modifier.size(4.dp))
Surface(
@@ -218,24 +253,41 @@ fun ChatInput(
onValueChange: (String) -> Unit,
onSend: () -> Unit
) {
Surface(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.imePadding(),
verticalAlignment = Alignment.CenterVertically
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.Bottom
) {
TextField(
value = value,
onValueChange = onValueChange,
placeholder = { Text("Message") },
modifier = Modifier.weight(1f),
shape = CircleShape,
shape = RoundedCornerShape(28.dp),
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.size(8.dp))
FilledTonalIconButton(
onClick = onSend,
modifier = Modifier
.fillMaxHeight()
.aspectRatio(1f),
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Icon(
painter = painterResource(Res.drawable.ic_send),
contentDescription = "Send"
)
}
}
}
}

View File

@@ -28,7 +28,7 @@ kotlin {
implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
implementation("su.reya:nostr-sdk-kmp:0.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)

View File

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

View File

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

View File

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