diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 5cfac06..9ed4aa7 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -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 } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 7960316..4173f86 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -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" + ) }, ) } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 9c7d9b2..dc3593a 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -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) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index fe67e6c..dfa53e4 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -39,6 +39,8 @@ class Nostr { private set var deviceSigner: NostrSigner? = null private set + var contactList: List = 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 = rumor.tags().publicKeys().toMutableList() pubkeys.add(rumor.author()) @@ -278,7 +282,8 @@ class Nostr { return uniqueSortedKeys.hashCode().toLong() } - suspend fun getDefaultRelayList(): Map { + + private suspend fun getDefaultRelayList(): Map { // Construct a list of relays val relayList = mapOf( RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ, @@ -302,7 +307,7 @@ class Nostr { return relayList } - suspend fun getMsgRelayList(): List { + private suspend fun getMsgRelayList(): List { // 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) { + 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? { + 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 = 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 + } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 1e86250..3447a9e 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -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>(emptySet()) + val chatRooms = _chatRooms.asStateFlow() + private val _metadataStore = mutableMapOf>() + private val metadataRequestChannel = Channel(Channel.UNLIMITED) + private val seenPublicKeys = mutableSetOf() + + init { + startMetadataBatchProcessor() + } + + private fun startMetadataBatchProcessor() { + viewModelScope.launch { + val batch = mutableSetOf() + 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 { - 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 diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt new file mode 100644 index 0000000..86c0f1a --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -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, + val kind: RoomKind = RoomKind.default() +) : Comparable { + 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 = 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 + ) + } + } + + 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 = 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() +}