new chat screen

This commit is contained in:
2026-05-15 17:09:59 +07:00
parent d56847f5d4
commit 6b448a56f8
11 changed files with 571 additions and 31 deletions

View File

@@ -1,7 +1,10 @@
package su.reya.coop
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
@@ -24,6 +27,8 @@ import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.LogLevel
import rust.nostr.sdk.Metadata
import rust.nostr.sdk.MetadataRecord
import rust.nostr.sdk.Nip05Address
import rust.nostr.sdk.Nip05Profile
import rust.nostr.sdk.NostrDatabase
import rust.nostr.sdk.NostrGossip
import rust.nostr.sdk.PublicKey
@@ -56,8 +61,6 @@ class Nostr {
private set
var msgRelayList: Map<PublicKey, List<RelayUrl>> = emptyMap()
private set
var contactList: List<PublicKey> = emptyList()
private set
suspend fun init(dbPath: String) {
try {
@@ -87,6 +90,12 @@ class Nostr {
client?.addRelay(RelayUrl.parse("wss://relay.primal.net"))
client?.addRelay(RelayUrl.parse("wss://user.kindpag.es"))
// Add search relay
client?.addRelay(
url = RelayUrl.parse("wss://antiprimal.net"),
capabilities = RelayCapabilities.read()
)
// Indexer relay for NIP-65 discovery
client?.addRelay(
url = RelayUrl.parse("wss://indexer.coracle.social"),
@@ -107,7 +116,6 @@ class Nostr {
suspend fun exit() {
signer.switch(Keys.generate())
deviceSigner = null
contactList = emptyList()
}
suspend fun setSigner(keys: AsyncNostrSigner) {
@@ -184,6 +192,7 @@ class Nostr {
suspend fun handleNotifications(
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
onContactListUpdate: (List<PublicKey>) -> Unit,
onNewMessage: (UnsignedEvent) -> Unit,
onEose: () -> Unit,
) = coroutineScope {
@@ -217,6 +226,12 @@ class Nostr {
}
}
if (event.kind().asStd()?.equals(KindStandard.CONTACT_LIST) == true) {
if (isSignedByUser(event = event)) {
onContactListUpdate(event.tags().publicKeys())
}
}
if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) {
if (isSignedByUser(event = event)) {
getUserMessages(msgRelayList = event)
@@ -457,7 +472,7 @@ class Nostr {
)
)
client?.subscribe(target = target, id = "metadata-reqs", closeOn = opts)
client?.subscribe(target = target, closeOn = opts)
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e)
}
@@ -494,13 +509,10 @@ class Nostr {
Filter().kind(kind).author(userPubkey).pubkeys(room.members.toList());
// Check if the user is interacting with the room's members
val isInteracting = client?.database()?.query(filter)?.isEmpty() == false;
// Check if the room's members are in the contact list
val isContact = contactList.containsAll(room.members)
val isOngoing = client?.database()?.query(filter)?.isEmpty() == false;
// Set the room kind based on interaction status
if (isInteracting || isContact) {
if (isOngoing) {
room.setKind(RoomKind.Ongoing)
}
@@ -614,4 +626,54 @@ class Nostr {
throw IllegalStateException("Failed to send message: ${e.message}", e)
}
}
suspend fun profileFromAddress(client: HttpClient, address: Nip05Address): Nip05Profile {
try {
val response: HttpResponse = client.get(address.url())
val bodyString: String = response.body()
return Nip05Profile.fromJson(address, bodyString)
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e)
}
}
suspend fun searchByAddress(query: String): List<PublicKey> {
try {
val address = Nip05Address.parse(query)
val profile = profileFromAddress(HttpClient(), address)
return listOf(profile.publicKey())
} catch (e: Exception) {
throw IllegalStateException("Failed to search address: ${e.message}", e)
}
}
suspend fun searchByNostr(query: String) {
try {
val kinds = listOf(Kind.fromStd(KindStandard.METADATA))
val filter = Filter().kinds(kinds).search(query).limit(10u)
val target =
ReqTarget.manual(mapOf(RelayUrl.parse("wss://antiprimal.net") to listOf(filter)))
val stream = client?.streamEvents(
target = target,
id = "search",
timeout = Duration.parse("4s"),
policy = ReqExitPolicy.ExitOnEose
)
// Collect the results
val results = mutableListOf<PublicKey>()
// Keep searching until the stream is closed or timeout
stream?.next()?.let { event ->
if (event.event != null) {
results.add(event.event!!.author())
}
}
} catch (e: Exception) {
throw IllegalStateException("Failed to search nostr: ${e.message}", e)
}
}
}

View File

@@ -17,12 +17,14 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import rust.nostr.sdk.EventBuilder
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.Tag
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.blossom.BlossomClient
import su.reya.coop.storage.SecretStorage
@@ -42,6 +44,9 @@ class NostrViewModel(
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
val chatRooms = _chatRooms.asStateFlow()
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList = _contactList.asStateFlow()
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow()
@@ -56,6 +61,16 @@ class NostrViewModel(
startMetadataBatchProcessor()
}
override fun onCleared() {
super.onCleared()
// Ensure all relays are disconnect
viewModelScope.launch {
withContext(NonCancellable) {
nostr.disconnect()
}
}
}
private fun showError(message: String) {
viewModelScope.launch {
_errorEvents.send(message)
@@ -133,6 +148,9 @@ class NostrViewModel(
onMetadataUpdate = { pubkey, metadata ->
updateMetadata(pubkey, metadata)
},
onContactListUpdate = { contactList ->
_contactList.value = contactList.toSet()
},
onEose = {
getChatRooms()
},
@@ -287,6 +305,23 @@ class NostrViewModel(
}
}
fun createChatRoom(to: List<PublicKey>): Long {
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")
// Construct the rumor event
val rumor = EventBuilder
.privateMsgRumor(to.first(), "")
.tags(to.map { Tag.publicKey(it) })
.build(nostr.signer.currentUser!!)
// Create a room from the rumor event
val room = Room.new(rumor, nostr.signer.currentUser!!)
_chatRooms.value += room
return room.id
}
fun getChatRoom(id: Long): Room {
return chatRooms.value.firstOrNull { it.id == id }
?: throw IllegalArgumentException("Room not found")
@@ -345,16 +380,6 @@ class NostrViewModel(
}
}
}
override fun onCleared() {
super.onCleared()
// Ensure all relays are disconnect
viewModelScope.launch {
withContext(NonCancellable) {
nostr.disconnect()
}
}
}
}
fun PublicKey.short(): String {

View File

@@ -111,7 +111,7 @@ fun Timestamp.ago(): String {
val duration = now - inputInstant
return when {
duration.inWholeSeconds < SECONDS_IN_MINUTE -> "now"
duration.inWholeSeconds < SECONDS_IN_MINUTE -> "Now"
duration.inWholeMinutes < MINUTES_IN_HOUR -> "${duration.inWholeMinutes}m"
duration.inWholeHours < HOURS_IN_DAY -> "${duration.inWholeHours}h"
duration.inWholeDays < DAYS_IN_MONTH -> "${duration.inWholeDays}d"