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
}