chore: merge the develop branch into master #1
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user