add get chat rooms

This commit is contained in:
2026-05-04 12:08:14 +07:00
parent e02338fd52
commit 109fe28d48
6 changed files with 259 additions and 41 deletions

View File

@@ -30,10 +30,13 @@ import su.reya.coop.screens.OnboardingScreen
@Composable
fun App(dbPath: String) {
val context = LocalContext.current
// Initialize Nostr and SecretStore
val nostr = remember { Nostr() }
val secretStore = remember { SecretStore(context) }
val viewModel: NostrViewModel = viewModel { NostrViewModel(nostr, secretStore) }
// Dynamic color scheme
val darkMode = isSystemInDarkTheme()
val colorScheme = when {
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
@@ -46,6 +49,7 @@ fun App(dbPath: String) {
LaunchedEffect(Unit) {
viewModel.initAndConnect(dbPath)
viewModel.getChatRooms()
}
MaterialExpressiveTheme(
@@ -60,7 +64,8 @@ fun App(dbPath: String) {
if (hasSecret == true) {
// Start a background notification handler
viewModel.startNotificationHandler()
// Get chat rooms
viewModel.getChatRooms()
// Navigate to the home screen
navController.navigate(Screen.Home) {
popUpTo(Screen.Onboarding) { inclusive = true }

View File

@@ -38,7 +38,10 @@ fun HomeScreen(onOpenChat: (String) -> Unit) {
searchBarState = searchState,
onSearch = { scope.launch { searchState.animateToCollapsed() } },
placeholder = {
Text(modifier = Modifier.clearAndSetSemantics() {}, text = "Search")
Text(
modifier = Modifier.clearAndSetSemantics() {},
text = "Find or start a conversation"
)
},
)
}

View File

@@ -26,7 +26,7 @@ kotlin {
commonMain.dependencies {
implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("su.reya:nostr-sdk-kmp:0.1.1")
implementation("su.reya:nostr-sdk-kmp:0.1.2")
}
commonTest.dependencies {
implementation(libs.kotlin.test)

View File

@@ -39,6 +39,8 @@ class Nostr {
private set
var deviceSigner: NostrSigner? = null
private set
var contactList: List<PublicKey> = emptyList()
private set
suspend fun init(dbPath: String) {
val lmdb = NostrDatabase.lmdb(dbPath)
@@ -211,7 +213,7 @@ class Nostr {
}
}
suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
try {
val filter = Filter().identifier(giftId.toBech32())
val event = client?.database()?.query(filter)?.first()
@@ -223,20 +225,22 @@ class Nostr {
return null
}
suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
if (rumor.id() == null) return
try {
val rngKeys = Keys.generate()
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(Keys.generate())
val event = EventBuilder(kind, rumor.asJson()).tags(tags).signWithKeys(rngKeys)
client?.database()?.saveEvent(event)
client?.database()?.saveEvent(rumor.signWithKeys(rngKeys))
} catch (e: Exception) {
// TODO: log error
}
}
suspend fun extractRumor(event: Event): UnsignedEvent? {
private suspend fun extractRumor(event: Event): UnsignedEvent? {
if (event.kind().asStd() != KindStandard.GIFT_WRAP) return null
// Check if the rumor is already cached
@@ -266,7 +270,7 @@ class Nostr {
return null
}
fun conversationId(rumor: UnsignedEvent): Long {
private fun conversationId(rumor: UnsignedEvent): Long {
val pubkeys: MutableList<PublicKey> = rumor.tags().publicKeys().toMutableList()
pubkeys.add(rumor.author())
@@ -278,7 +282,8 @@ class Nostr {
return uniqueSortedKeys.hashCode().toLong()
}
suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
private suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
// Construct a list of relays
val relayList = mapOf<RelayUrl, RelayMetadata>(
RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ,
@@ -302,7 +307,7 @@ class Nostr {
return relayList
}
suspend fun getMsgRelayList(): List<RelayUrl> {
private suspend fun getMsgRelayList(): List<RelayUrl> {
// Construct a list of messaging relays
val msgRelayList = listOf(
RelayUrl.parse("wss://relay.0xchat.com"),
@@ -344,4 +349,67 @@ class Nostr {
val contactListEvent = EventBuilder.contactList(defaultContact).sign(signer!!)
client?.sendEventNoWait(contactListEvent)
}
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
val filter =
Filter()
.kind(Kind.fromStd(KindStandard.METADATA))
.authors(keys)
.limit(keys.size.toULong())
val target =
ReqTarget.manual(mapOf(RelayUrl.parse("wss://user.kindpag.es") to listOf(filter)))
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
client?.subscribe(target = target, id = "metadata-reqs", closeOn = opts)
}
suspend fun getChatRooms(): Set<Room>? {
try {
val userPubkey = signer?.getPublicKey() ?: return null
val kind = Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE)
// Get all events sent by the user
val sendFilter = Filter().kind(kind).author(userPubkey)
val sendEvents = client?.database()?.query(sendFilter);
// 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();
val rooms: MutableSet<Room> = mutableSetOf()
events
?.filter { it.tags().publicKeys().isNotEmpty() }
?.sortedByDescending { it.createdAt().asSecs() }
?.forEach { event ->
val room = Room.new(rumor = event, userPubkey = userPubkey)
// Check if the room already exists
if (rooms.contains(room)) return@forEach
val filter =
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)
// Set the room kind based on interaction status
if (isInteracting || isContact) {
room.kind(RoomKind.Ongoing)
}
rooms.add(room)
}
return rooms
} catch (e: Exception) {
println("Failed to get chat rooms: ${e.message}")
}
return null
}
}

View File

@@ -3,17 +3,20 @@ package su.reya.coop
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
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 su.reya.coop.storage.SecretStorage
import kotlin.time.Clock
import kotlin.time.Duration
class NostrViewModel(
@@ -26,11 +29,61 @@ class NostrViewModel(
private val _isCreating = MutableStateFlow(false)
val isCreating = _isCreating.asStateFlow()
// User metadata store
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
val chatRooms = _chatRooms.asStateFlow()
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>()
init {
startMetadataBatchProcessor()
}
private fun startMetadataBatchProcessor() {
viewModelScope.launch {
val batch = mutableSetOf<PublicKey>()
val timeout = 500L // 500ms timeout for batching
while (true) {
val firstKey = metadataRequestChannel.receive()
batch.add(firstKey)
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
while (batch.isNotEmpty()) {
val nextKey = withTimeoutOrNull(timeout) {
metadataRequestChannel.receive()
}
if (nextKey != null) {
batch.add(nextKey)
}
val now = Clock.System.now().toEpochMilliseconds()
if (batch.size >= 20 || (now - lastFlushTime) >= timeout || nextKey == null) {
val keysToRequest = batch.toList()
batch.clear()
nostr.fetchMetadataBatch(keysToRequest)
}
}
}
}
}
fun requestMetadata(pubkey: PublicKey) {
if (seenPublicKeys.add(pubkey)) {
viewModelScope.launch {
metadataRequestChannel.send(pubkey)
}
}
}
fun getMetadata(pubkey: PublicKey): StateFlow<Metadata?> {
return _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.asStateFlow()
val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }
if (flow.value == null) {
requestMetadata(pubkey)
}
return flow.asStateFlow()
}
fun updateMetadata(pubkey: PublicKey, metadata: Metadata) {
@@ -42,37 +95,10 @@ class NostrViewModel(
try {
// Initialize nostr client
nostr.init(dbPath)
// Connect to bootstrap relays
nostr.connect()
// Get user's signer secret
val secret = secretStore.get("user_signer")
// If no secret is found, show onboarding screen
if (secret == null) {
_hasSecret.value = false
return@launch
}
_hasSecret.value = true
// Handle different signer types
if (secret.startsWith("nsec1")) {
val keys = Keys.parse(secret)
nostr.setKeySigner(keys)
} else if (secret.startsWith("bunker://")) {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val remote = NostrConnect(
uri = bunker,
appKeys = appKeys,
timeout = Duration.parse("5"),
opts = null
)
nostr.setRemoteSigner(remote)
} else {
throw IllegalArgumentException("Invalid secret format: $secret")
}
// Get user's secret
getUserSecret()
} catch (e: Exception) {
println("Failed to connect: ${e.message}")
}
@@ -87,6 +113,36 @@ class NostrViewModel(
}
}
suspend fun getUserSecret() {
// Get user's signer secret
val secret = secretStore.get("user_signer")
// If no secret is found, show onboarding screen
if (secret == null) {
_hasSecret.value = false
return
}
_hasSecret.value = true
// Handle different signer types
if (secret.startsWith("nsec1")) {
val keys = Keys.parse(secret)
nostr.setKeySigner(keys)
} else if (secret.startsWith("bunker://")) {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val remote = NostrConnect(
uri = bunker,
appKeys = appKeys,
timeout = Duration.parse("5"),
opts = null
)
nostr.setRemoteSigner(remote)
} else {
throw IllegalArgumentException("Invalid secret format: $secret")
}
}
suspend fun getOrInitAppKeys(): Keys {
val secret = secretStore.get("app_keys")
@@ -123,6 +179,16 @@ class NostrViewModel(
// TODO: Implement import
}
fun getChatRooms() {
viewModelScope.launch {
try {
_chatRooms.value = nostr.getChatRooms() ?: emptySet()
} catch (e: Exception) {
println("Failed to get chat rooms: ${e.message}")
}
}
}
override fun onCleared() {
super.onCleared()
// Ensure all relays are disconnect

View File

@@ -0,0 +1,76 @@
package su.reya.coop
import rust.nostr.sdk.Event
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.TagKind
import rust.nostr.sdk.Timestamp
enum class RoomKind {
Ongoing,
Request;
companion object {
fun default(): RoomKind = Request
}
}
data class Room(
val id: Long,
val createdAt: Timestamp,
val subject: String?,
val members: Set<PublicKey>,
val kind: RoomKind = RoomKind.default()
) : Comparable<Room> {
override fun hashCode(): Int = id.hashCode()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Room) return false
return id == other.id
}
override fun compareTo(other: Room): Int {
return this.createdAt.asSecs().compareTo(other.createdAt.asSecs())
}
companion object {
fun new(rumor: Event, userPubkey: PublicKey): Room {
val id = rumor.roomId()
val createdAt = rumor.createdAt()
val subject = rumor.tags().find(TagKind.Subject)?.content()
// Collect the author's public key and all public keys from tags
// Also remove the user's public key from the list
val pubkeys: MutableSet<PublicKey> = mutableSetOf()
pubkeys.add(rumor.author())
pubkeys.addAll(rumor.tags().publicKeys())
pubkeys.remove(userPubkey)
// Create a new Room instance
return Room(
id = id,
createdAt = createdAt,
subject = subject,
members = pubkeys as Set<PublicKey>
)
}
}
fun kind(kind: RoomKind): Room {
return this.copy(kind = kind)
}
}
fun Event.roomId(): Long {
// Collect the author's public key and all public keys from tags
val pubkeys: MutableList<PublicKey> = mutableListOf()
pubkeys.add(this.author())
pubkeys.addAll(this.tags().publicKeys())
// Sort and hash the list of public keys
val sortedUniqueKeys = pubkeys
.distinctBy { it.toBech32() }
.sortedBy { it.toBech32() }
return sortedUniqueKeys.hashCode().toLong()
}