refactor rumor cache

This commit is contained in:
2026-05-14 14:44:07 +07:00
parent b0fcb05cdf
commit c8be6af0fb
6 changed files with 52 additions and 64 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.2") implementation("su.reya:nostr-sdk-kmp:0.2.3")
} }
commonMain.dependencies { commonMain.dependencies {
implementation(libs.compose.runtime) implementation(libs.compose.runtime)

View File

@@ -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_avatar
import coop.composeapp.generated.resources.ic_send 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.UnsignedEvent
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
@@ -76,7 +76,7 @@ fun ChatScreen(
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
val messages = remember { mutableStateListOf<Event>() } val messages = remember { mutableStateListOf<UnsignedEvent>() }
fun setLoading(value: Boolean) { fun setLoading(value: Boolean) {
loading = value loading = value
@@ -177,7 +177,7 @@ fun ChatScreen(
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
reverseLayout = true reverseLayout = true
) { ) {
items(messages.toList(), key = { it.id().toBech32() }) { event -> items(messages.toList(), key = { it.id()?.toBech32()!! }) { event ->
ChatMessage(event) ChatMessage(event)
} }
} }
@@ -198,11 +198,11 @@ fun ChatScreen(
@Composable @Composable
fun ChatMessage( fun ChatMessage(
event: Event rumor: UnsignedEvent
) { ) {
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val currentUser = viewModel.currentUser() val currentUser = viewModel.currentUser()
val isMine = event.author() == currentUser val isMine = rumor.author() == currentUser
val bubbleShape = if (isMine) { val bubbleShape = if (isMine) {
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp) 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 horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
) { ) {
Text( Text(
text = event.createdAt().humanReadable(), text = rumor.createdAt().humanReadable(),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
textAlign = if (isMine) TextAlign.End else TextAlign.Start, textAlign = if (isMine) TextAlign.End else TextAlign.Start,
) )
@@ -238,7 +238,7 @@ fun ChatMessage(
modifier = Modifier.widthIn(max = 280.dp) modifier = Modifier.widthIn(max = 280.dp)
) { ) {
Text( Text(
text = event.content(), text = rumor.content(),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )

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.2") implementation("su.reya:nostr-sdk-kmp:0.2.3")
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

@@ -7,6 +7,7 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import rust.nostr.sdk.AckPolicy import rust.nostr.sdk.AckPolicy
import rust.nostr.sdk.Alphabet
import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.Client import rust.nostr.sdk.Client
import rust.nostr.sdk.ClientBuilder import rust.nostr.sdk.ClientBuilder
@@ -33,6 +34,7 @@ import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.ReqExitPolicy import rust.nostr.sdk.ReqExitPolicy
import rust.nostr.sdk.ReqTarget import rust.nostr.sdk.ReqTarget
import rust.nostr.sdk.SendEventTarget import rust.nostr.sdk.SendEventTarget
import rust.nostr.sdk.SingleLetterTag
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
@@ -182,8 +184,8 @@ class Nostr {
suspend fun handleNotifications( suspend fun handleNotifications(
onMetadataUpdate: (PublicKey, Metadata) -> Unit, onMetadataUpdate: (PublicKey, Metadata) -> Unit,
onNewMessage: (UnsignedEvent) -> Unit,
onEose: () -> Unit, onEose: () -> Unit,
onNewMessage: (Event) -> Unit
) = coroutineScope { ) = coroutineScope {
val now = Timestamp.now() val now = Timestamp.now()
val processedEvent = mutableSetOf<EventId>() val processedEvent = mutableSetOf<EventId>()
@@ -239,8 +241,7 @@ class Nostr {
// Handle new message // Handle new message
rumor?.createdAt()?.asSecs()?.let { rumor?.createdAt()?.asSecs()?.let {
if (it >= now.asSecs()) { if (it >= now.asSecs()) {
// TODO: only send unsigned event onNewMessage(rumor)
onNewMessage(rumor.signWithKeys(Keys.generate()))
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -281,7 +282,7 @@ class Nostr {
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.toHex())
val event = client?.database()?.query(filter)?.first() val event = client?.database()?.query(filter)?.first()
return event?.content()?.let { UnsignedEvent.fromJson(it) } return event?.content()?.let { UnsignedEvent.fromJson(it) }
@@ -292,18 +293,30 @@ class Nostr {
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
try { try {
val rngKeys = Keys.generate() val currentUser =
signer.currentUser ?: throw IllegalStateException("User not signed in")
// Ensure the rumor ID is set // Ensure the rumor ID is set
val rumor = rumor.ensureId() 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 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(event)
client?.database()?.saveEvent(rumor.signWithKeys(rngKeys))
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to set cached rumor: ${e.message}") println("Failed to set cached rumor: ${e.message}")
} }
@@ -337,19 +350,6 @@ class Nostr {
return null return null
} }
private fun conversationId(rumor: UnsignedEvent): Long {
val pubkeys: MutableList<PublicKey> = 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<RelayUrl, RelayMetadata> { private suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
// Construct a list of relays // Construct a list of relays
val relayList = mapOf<RelayUrl, RelayMetadata>( val relayList = mapOf<RelayUrl, RelayMetadata>(
@@ -466,21 +466,19 @@ class Nostr {
suspend fun getChatRooms(): Set<Room>? { suspend fun getChatRooms(): Set<Room>? {
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.APPLICATION_SPECIFIC_DATA)
val kTag = SingleLetterTag.lowercase(Alphabet.K)
// Get all events sent by the user // Get all events sent by the user
val sendFilter = Filter().kind(kind).author(userPubkey) val filter = Filter().kind(kind).author(userPubkey).customTag(kTag, "dm")
val sendEvents = client?.database()?.query(sendFilter); val events = client?.database()?.query(filter)
// Get all events sent to the user // Collect rooms
val recvFilter = Filter().kind(kind).pubkey(userPubkey)
val recvEvents = client?.database()?.query(recvFilter);
// Collect all events
val events = sendEvents?.merge(recvEvents!!)?.toVec();
val rooms: MutableSet<Room> = mutableSetOf() val rooms: MutableSet<Room> = mutableSetOf()
events events
?.toVec()
?.map { UnsignedEvent.fromJson(it.content()) }
?.filter { it.tags().publicKeys().isNotEmpty() } ?.filter { it.tags().publicKeys().isNotEmpty() }
?.sortedByDescending { it.createdAt().asSecs() } ?.sortedByDescending { it.createdAt().asSecs() }
?.forEach { event -> ?.forEach { event ->
@@ -516,24 +514,17 @@ class Nostr {
} }
} }
suspend fun getChatRoomMessages(members: List<PublicKey>): List<Event> { suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
try { try {
val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in") val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
val kind = Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE) val filter = Filter().kind(kind).reference(roomId.toString())
val events = client?.database()?.query(filter)
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)
// Merge the events // Merge the events
val events = sendEvents return events
?.merge(recvEvents!!)
?.toVec() ?.toVec()
?.sortedByDescending { it.createdAt().asSecs() } ?.map { UnsignedEvent.fromJson(it.content()) }
?.sortedByDescending { it.createdAt().asSecs() } ?: 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)
} }

View File

@@ -17,13 +17,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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.EventId 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
import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.blossom.BlossomClient import su.reya.coop.blossom.BlossomClient
import su.reya.coop.storage.SecretStorage import su.reya.coop.storage.SecretStorage
import kotlin.time.Clock import kotlin.time.Clock
@@ -42,7 +42,7 @@ 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) private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow() val newEvents = _newEvents.asSharedFlow()
private val _errorEvents = Channel<String>(Channel.BUFFERED) private val _errorEvents = Channel<String>(Channel.BUFFERED)
@@ -302,12 +302,9 @@ class NostrViewModel(
} }
} }
suspend fun getChatRoomMessages(roomId: Long): List<Event> { suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
try { try {
val room = chatRooms.value.firstOrNull { it.id == roomId } ?: return emptyList() return nostr.getChatRoomMessages(roomId)
val members = room.members
return nostr.getChatRoomMessages(members.toList())
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} }

View File

@@ -5,10 +5,10 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus import kotlinx.datetime.minus
import kotlinx.datetime.number import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import rust.nostr.sdk.Event
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.TagKind import rust.nostr.sdk.TagKind
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@@ -42,7 +42,7 @@ data class Room(
} }
companion object { companion object {
fun new(rumor: Event, userPubkey: PublicKey): Room { fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
val id = rumor.roomId() val id = rumor.roomId()
val createdAt = rumor.createdAt() val createdAt = rumor.createdAt()
val subject = rumor.tags().find(TagKind.Subject)?.content() 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 // Collect the author's public key and all public keys from tags
val pubkeys: MutableList<PublicKey> = mutableListOf() val pubkeys: MutableList<PublicKey> = mutableListOf()
pubkeys.add(this.author()) pubkeys.add(this.author())