From b0fcb05cdf37efe473a4c90db48c5f9723c79b9c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 13 May 2026 15:37:09 +0700 Subject: [PATCH] update nostr class --- composeApp/build.gradle.kts | 2 +- .../composeResources/drawable/ic_send.xml | 9 + .../kotlin/su/reya/coop/screens/ChatScreen.kt | 76 +++++++-- shared/build.gradle.kts | 2 +- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 155 ++++++++++++++++-- .../kotlin/su/reya/coop/NostrViewModel.kt | 51 +++++- .../commonMain/kotlin/su/reya/coop/Room.kt | 4 + 7 files changed, 266 insertions(+), 33 deletions(-) create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_send.xml diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b66a51a..ae735f1 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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) diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_send.xml b/composeApp/src/androidMain/composeResources/drawable/ic_send.xml new file mode 100644 index 0000000..f1a96c4 --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_send.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index b5a1357..ff08960 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -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>(emptyList()) } var loading by remember { mutableStateOf(true) } + val messages = remember { mutableStateListOf() } + + 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" + ) + } } } } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 583be7f..5808604 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -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) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index aa788f8..470572f 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -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> = emptyMap() + private set var contactList: List = 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() - 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): List { 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) { + 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, + content: String, + subject: String? = null, + replies: List = emptyList() + ) { + try { + val currentUser = + signer.currentUser ?: throw IllegalStateException("User not signed in") + + val tags = mutableListOf() + + // 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) + } + } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 97e590c..ca0325d 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -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>(emptySet()) val chatRooms = _chatRooms.asStateFlow() + private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) + val newEvents = _newEvents.asSharedFlow() + private val _errorEvents = Channel(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 = 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 diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index 1b2a7cb..bc19239 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -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 }