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("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.2.1") implementation("su.reya:nostr-sdk-kmp:0.2.2")
} }
commonMain.dependencies { commonMain.dependencies {
implementation(libs.compose.runtime) 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn 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.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.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -31,6 +36,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -39,16 +45,19 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
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_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_avatar import coop.composeapp.generated.resources.ic_avatar
import coop.composeapp.generated.resources.ic_send
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Event import rust.nostr.sdk.Event
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.humanReadable import su.reya.coop.humanReadable
import su.reya.coop.roomId
import su.reya.coop.shared.displayNameFlow import su.reya.coop.shared.displayNameFlow
import su.reya.coop.shared.pictureFlow import su.reya.coop.shared.pictureFlow
@@ -59,18 +68,41 @@ fun ChatScreen(
) { ) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val room = viewModel.getChatRoom(id) val room = viewModel.getChatRoom(id)
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...") val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null) val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
var messages by remember { mutableStateOf<List<Event>>(emptyList()) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
val messages = remember { mutableStateListOf<Event>() }
fun setLoading(value: Boolean) {
loading = value
}
LaunchedEffect(id) { LaunchedEffect(id) {
loading = true // Start loading spinner
messages = viewModel.getChatRoomMessages(id) setLoading(true)
loading = false
// 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( Scaffold(
@@ -153,7 +185,7 @@ fun ChatScreen(
value = text, value = text,
onValueChange = { text = it }, onValueChange = { text = it },
onSend = { onSend = {
// TODO: Implement send logic viewModel.sendMessage(id, text)
text = "" text = ""
} }
) )
@@ -190,10 +222,13 @@ fun ChatMessage(
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
) { ) {
Column { Column(
horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
) {
Text( Text(
text = event.createdAt().humanReadable(), text = event.createdAt().humanReadable(),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
textAlign = if (isMine) TextAlign.End else TextAlign.Start,
) )
Spacer(modifier = Modifier.size(4.dp)) Spacer(modifier = Modifier.size(4.dp))
Surface( Surface(
@@ -218,24 +253,41 @@ fun ChatInput(
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
onSend: () -> Unit onSend: () -> Unit
) { ) {
Surface(modifier = Modifier.fillMaxWidth()) { Surface(modifier = Modifier.fillMaxWidth()) {
Row( Row(
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp) .padding(horizontal = 16.dp, vertical = 8.dp)
.imePadding(), .height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.Bottom
) { ) {
TextField( TextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
placeholder = { Text("Message") }, placeholder = { Text("Message") },
modifier = Modifier.weight(1f), shape = RoundedCornerShape(28.dp),
shape = CircleShape,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent, focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = 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.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.2.1") implementation("su.reya:nostr-sdk-kmp:0.2.2")
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,10 @@ 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 kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import rust.nostr.sdk.AckPolicy import rust.nostr.sdk.AckPolicy
import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.Client import rust.nostr.sdk.Client
@@ -32,10 +36,12 @@ 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.TagKind
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.initLogger import rust.nostr.sdk.initLogger
import rust.nostr.sdk.makePrivateMsgAsync
import rust.nostr.sdk.nip17ExtractRelayList import rust.nostr.sdk.nip17ExtractRelayList
import kotlin.time.Duration import kotlin.time.Duration
@@ -46,6 +52,8 @@ class Nostr {
private set private set
var deviceSigner: AsyncNostrSigner? = null var deviceSigner: AsyncNostrSigner? = null
private set private set
var msgRelayList: Map<PublicKey, List<RelayUrl>> = emptyMap()
private set
var contactList: List<PublicKey> = emptyList() var contactList: List<PublicKey> = emptyList()
private set private set
@@ -94,7 +102,8 @@ class Nostr {
client?.shutdown() client?.shutdown()
} }
fun exit() { suspend fun exit() {
signer.switch(Keys.generate())
deviceSigner = null deviceSigner = null
contactList = emptyList() contactList = emptyList()
} }
@@ -164,17 +173,23 @@ class Nostr {
client?.subscribe( client?.subscribe(
target = ReqTarget.manual(target), target = ReqTarget.manual(target),
id = "user-messages" id = "messages"
) )
} 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)
} }
} }
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 now = Timestamp.now()
val processedEvent = mutableSetOf<EventId>() val processedEvent = mutableSetOf<EventId>()
val notifications = client?.notifications() ?: return val notifications = client?.notifications() ?: return@coroutineScope
var eoseTrackerJob: Job? = null
while (true) { while (true) {
val notification = notifications.next() ?: continue val notification = notifications.next() ?: continue
@@ -204,12 +219,30 @@ class Nostr {
if (isSignedByUser(event = event)) { if (isSignedByUser(event = event)) {
getUserMessages(msgRelayList = 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) { if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
try { try {
val rumor = extractRumor(event) 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) { } catch (e: Exception) {
println("Failed to extract rumor: $e") println("Failed to extract rumor: $e")
} }
@@ -218,7 +251,10 @@ class Nostr {
is RelayMessageEnum.EndOfStoredEvents -> { is RelayMessageEnum.EndOfStoredEvents -> {
val subscriptionId = message.subscriptionId val subscriptionId = message.subscriptionId
// TODO: Handle end of stored events
if (subscriptionId == "messages") {
onEose()
}
} }
else -> { 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? { private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
try { try {
val filter = Filter().identifier(giftId.toBech32()) val filter = Filter().identifier(giftId.toBech32())
@@ -245,16 +286,18 @@ class Nostr {
return event?.content()?.let { UnsignedEvent.fromJson(it) } return event?.content()?.let { UnsignedEvent.fromJson(it) }
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to get cached rumor: ${e.message}") throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
return null
} }
} }
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
if (rumor.id() == null) return
try { try {
val rngKeys = Keys.generate() 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 kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA);
val tags = listOf(Tag.identifier(giftId.toBech32()), Tag.event(rumor.id()!!)) val tags = listOf(Tag.identifier(giftId.toBech32()), Tag.event(rumor.id()!!))
val event = EventBuilder(kind, rumor.asJson()).tags(tags).signWithKeys(rngKeys) val event = EventBuilder(kind, rumor.asJson()).tags(tags).signWithKeys(rngKeys)
@@ -444,7 +487,10 @@ class Nostr {
val room = Room.new(rumor = event, userPubkey = userPubkey) val room = Room.new(rumor = event, userPubkey = userPubkey)
// Check if the room already exists // 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 = val filter =
Filter().kind(kind).author(userPubkey).pubkeys(room.members.toList()); Filter().kind(kind).author(userPubkey).pubkeys(room.members.toList());
@@ -473,18 +519,95 @@ class Nostr {
suspend fun getChatRoomMessages(members: List<PublicKey>): List<Event> { suspend fun getChatRoomMessages(members: List<PublicKey>): List<Event> {
try { try {
val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in") 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)
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 sendEvents = client?.database()?.query(sendFilter)
val recvFilter = Filter().kind(kind).authors(members).pubkey(userPubkey)
val recvEvents = client?.database()?.query(recvFilter) 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() return events ?: emptyList()
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e) 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 io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -16,6 +18,7 @@ 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.Event
import rust.nostr.sdk.EventId
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
@@ -39,6 +42,9 @@ class NostrViewModel(
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet()) private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
val chatRooms = _chatRooms.asStateFlow() val chatRooms = _chatRooms.asStateFlow()
private val _newEvents = MutableSharedFlow<Event>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow()
private val _errorEvents = Channel<String>(Channel.BUFFERED) private val _errorEvents = Channel<String>(Channel.BUFFERED)
val errorEvents = _errorEvents.receiveAsFlow() val errorEvents = _errorEvents.receiveAsFlow()
@@ -123,9 +129,19 @@ class NostrViewModel(
fun startNotificationHandler() { fun startNotificationHandler() {
viewModelScope.launch { viewModelScope.launch {
nostr.handleNotifications { pubkey, metadata -> nostr.handleNotifications(
updateMetadata(pubkey, metadata) onMetadataUpdate = { pubkey, metadata ->
} updateMetadata(pubkey, metadata)
},
onEose = {
getChatRooms()
},
onNewMessage = { event ->
viewModelScope.launch {
_newEvents.emit(event)
}
},
)
} }
} }
@@ -299,6 +315,35 @@ class NostrViewModel(
return emptyList() 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() { override fun onCleared() {
super.onCleared() super.onCleared()
// Ensure all relays are disconnect // Ensure all relays are disconnect

View File

@@ -77,6 +77,10 @@ data class Room(
return this.copy(subject = subject) return this.copy(subject = subject)
} }
fun setLastMessage(message: String?): Room {
return this.copy(lastMessage = message)
}
fun isGroup(): Boolean { fun isGroup(): Boolean {
return members.size > 1 return members.size > 1
} }