diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ae735f1..4e3d9f4 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.2") + implementation("su.reya:nostr-sdk-kmp:0.2.3") } commonMain.dependencies { implementation(libs.compose.runtime) 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 ff08960..a8fb6d5 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -53,7 +53,7 @@ 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 rust.nostr.sdk.UnsignedEvent import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.humanReadable @@ -76,7 +76,7 @@ fun ChatScreen( var text by remember { mutableStateOf("") } var loading by remember { mutableStateOf(true) } - val messages = remember { mutableStateListOf() } + val messages = remember { mutableStateListOf() } fun setLoading(value: Boolean) { loading = value @@ -177,7 +177,7 @@ fun ChatScreen( contentPadding = PaddingValues(16.dp), reverseLayout = true ) { - items(messages.toList(), key = { it.id().toBech32() }) { event -> + items(messages.toList(), key = { it.id()?.toBech32()!! }) { event -> ChatMessage(event) } } @@ -198,11 +198,11 @@ fun ChatScreen( @Composable fun ChatMessage( - event: Event + rumor: UnsignedEvent ) { val viewModel = LocalNostrViewModel.current val currentUser = viewModel.currentUser() - val isMine = event.author() == currentUser + val isMine = rumor.author() == currentUser val bubbleShape = if (isMine) { RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp) @@ -226,7 +226,7 @@ fun ChatMessage( horizontalAlignment = if (isMine) Alignment.End else Alignment.Start ) { Text( - text = event.createdAt().humanReadable(), + text = rumor.createdAt().humanReadable(), style = MaterialTheme.typography.labelSmall, textAlign = if (isMine) TextAlign.End else TextAlign.Start, ) @@ -238,7 +238,7 @@ fun ChatMessage( modifier = Modifier.widthIn(max = 280.dp) ) { Text( - text = event.content(), + text = rumor.content(), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), style = MaterialTheme.typography.bodyMedium ) diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 5808604..dd1d5c4 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.2") + implementation("su.reya:nostr-sdk-kmp:0.2.3") 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 470572f..107d4cb 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import rust.nostr.sdk.AckPolicy +import rust.nostr.sdk.Alphabet import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.Client import rust.nostr.sdk.ClientBuilder @@ -33,6 +34,7 @@ import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.ReqExitPolicy import rust.nostr.sdk.ReqTarget import rust.nostr.sdk.SendEventTarget +import rust.nostr.sdk.SingleLetterTag import rust.nostr.sdk.SleepWhenIdle import rust.nostr.sdk.SubscribeAutoCloseOptions import rust.nostr.sdk.Tag @@ -182,8 +184,8 @@ class Nostr { suspend fun handleNotifications( onMetadataUpdate: (PublicKey, Metadata) -> Unit, + onNewMessage: (UnsignedEvent) -> Unit, onEose: () -> Unit, - onNewMessage: (Event) -> Unit ) = coroutineScope { val now = Timestamp.now() val processedEvent = mutableSetOf() @@ -239,8 +241,7 @@ class Nostr { // Handle new message rumor?.createdAt()?.asSecs()?.let { if (it >= now.asSecs()) { - // TODO: only send unsigned event - onNewMessage(rumor.signWithKeys(Keys.generate())) + onNewMessage(rumor) } } } catch (e: Exception) { @@ -281,7 +282,7 @@ class Nostr { private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? { try { - val filter = Filter().identifier(giftId.toBech32()) + val filter = Filter().identifier(giftId.toHex()) val event = client?.database()?.query(filter)?.first() return event?.content()?.let { UnsignedEvent.fromJson(it) } @@ -292,18 +293,30 @@ class Nostr { private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { try { - val rngKeys = Keys.generate() + val currentUser = + signer.currentUser ?: throw IllegalStateException("User not signed in") // Ensure the rumor ID is set val rumor = rumor.ensureId() + val roomId = rumor.roomId() - // Construct a reference event + // Construct reference tags + val tags = listOf( + Tag.identifier(giftId.toHex()), + Tag.event(rumor.id()!!), + Tag.reference(roomId.toString()), + Tag.custom(TagKind.Unknown("k"), listOf("dm")) + ) + + // Set event kind 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) + + val event = EventBuilder(kind, rumor.asJson()) + .tags(tags) + .build(currentUser) + .signWithKeys(Keys.generate()) client?.database()?.saveEvent(event) - client?.database()?.saveEvent(rumor.signWithKeys(rngKeys)) } catch (e: Exception) { println("Failed to set cached rumor: ${e.message}") } @@ -337,19 +350,6 @@ class Nostr { return null } - private fun conversationId(rumor: UnsignedEvent): Long { - val pubkeys: MutableList = rumor.tags().publicKeys().toMutableList() - pubkeys.add(rumor.author()) - - val uniqueSortedKeys = pubkeys - .map { it.toHex() } - .distinct() - .sorted() - - return uniqueSortedKeys.hashCode().toLong() - } - - private suspend fun getDefaultRelayList(): Map { // Construct a list of relays val relayList = mapOf( @@ -466,21 +466,19 @@ class Nostr { suspend fun getChatRooms(): Set? { try { val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in") - val kind = Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE) + val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) + val kTag = SingleLetterTag.lowercase(Alphabet.K) // Get all events sent by the user - val sendFilter = Filter().kind(kind).author(userPubkey) - val sendEvents = client?.database()?.query(sendFilter); + val filter = Filter().kind(kind).author(userPubkey).customTag(kTag, "dm") + val events = client?.database()?.query(filter) - // Get all events sent to the user - val recvFilter = Filter().kind(kind).pubkey(userPubkey) - val recvEvents = client?.database()?.query(recvFilter); - - // Collect all events - val events = sendEvents?.merge(recvEvents!!)?.toVec(); + // Collect rooms val rooms: MutableSet = mutableSetOf() events + ?.toVec() + ?.map { UnsignedEvent.fromJson(it.content()) } ?.filter { it.tags().publicKeys().isNotEmpty() } ?.sortedByDescending { it.createdAt().asSecs() } ?.forEach { event -> @@ -516,24 +514,17 @@ class Nostr { } } - suspend fun getChatRoomMessages(members: List): List { + suspend fun getChatRoomMessages(roomId: Long): 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 sendEvents = client?.database()?.query(sendFilter) - - val recvFilter = Filter().kind(kind).authors(members).pubkey(userPubkey) - val recvEvents = client?.database()?.query(recvFilter) + val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) + val filter = Filter().kind(kind).reference(roomId.toString()) + val events = client?.database()?.query(filter) // Merge the events - val events = sendEvents - ?.merge(recvEvents!!) + return events ?.toVec() - ?.sortedByDescending { it.createdAt().asSecs() } - - return events ?: emptyList() + ?.map { UnsignedEvent.fromJson(it.content()) } + ?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList() } catch (e: Exception) { throw IllegalStateException("Failed to get chat room messages: ${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 ca0325d..d55ad51 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -17,13 +17,13 @@ import kotlinx.coroutines.launch 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 import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.UnsignedEvent import su.reya.coop.blossom.BlossomClient import su.reya.coop.storage.SecretStorage import kotlin.time.Clock @@ -42,7 +42,7 @@ class NostrViewModel( private val _chatRooms = MutableStateFlow>(emptySet()) val chatRooms = _chatRooms.asStateFlow() - private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) + private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) val newEvents = _newEvents.asSharedFlow() private val _errorEvents = Channel(Channel.BUFFERED) @@ -302,12 +302,9 @@ class NostrViewModel( } } - suspend fun getChatRoomMessages(roomId: Long): List { + suspend fun getChatRoomMessages(roomId: Long): List { try { - val room = chatRooms.value.firstOrNull { it.id == roomId } ?: return emptyList() - val members = room.members - - return nostr.getChatRoomMessages(members.toList()) + return nostr.getChatRoomMessages(roomId) } catch (e: Exception) { showError("Error: ${e.message}") } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index bc19239..22b65bc 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -5,10 +5,10 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.minus import kotlinx.datetime.number import kotlinx.datetime.toLocalDateTime -import rust.nostr.sdk.Event import rust.nostr.sdk.PublicKey import rust.nostr.sdk.TagKind import rust.nostr.sdk.Timestamp +import rust.nostr.sdk.UnsignedEvent import kotlin.time.Clock import kotlin.time.Instant @@ -42,7 +42,7 @@ data class Room( } companion object { - fun new(rumor: Event, userPubkey: PublicKey): Room { + fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room { val id = rumor.roomId() val createdAt = rumor.createdAt() val subject = rumor.tags().find(TagKind.Subject)?.content() @@ -86,7 +86,7 @@ data class Room( } } -fun Event.roomId(): Long { +fun UnsignedEvent.roomId(): Long { // Collect the author's public key and all public keys from tags val pubkeys: MutableList = mutableListOf() pubkeys.add(this.author())