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.2")
|
implementation("su.reya:nostr-sdk-kmp:0.2.3")
|
||||||
}
|
}
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user