chore: merge the develop branch into master #1

Merged
reya merged 43 commits from develop into master 2026-05-23 00:50:13 +00:00
6 changed files with 259 additions and 41 deletions
Showing only changes of commit 109fe28d48 - Show all commits

View File

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

View File

@@ -38,7 +38,10 @@ fun HomeScreen(onOpenChat: (String) -> Unit) {
searchBarState = searchState, searchBarState = searchState,
onSearch = { scope.launch { searchState.animateToCollapsed() } }, onSearch = { scope.launch { searchState.animateToCollapsed() } },
placeholder = { 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 { commonMain.dependencies {
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("su.reya:nostr-sdk-kmp:0.1.1") implementation("su.reya:nostr-sdk-kmp:0.1.2")
} }
commonTest.dependencies { commonTest.dependencies {
implementation(libs.kotlin.test) implementation(libs.kotlin.test)

View File

@@ -39,6 +39,8 @@ class Nostr {
private set private set
var deviceSigner: NostrSigner? = null var deviceSigner: NostrSigner? = null
private set private set
var contactList: List<PublicKey> = emptyList()
private set
suspend fun init(dbPath: String) { suspend fun init(dbPath: String) {
val lmdb = NostrDatabase.lmdb(dbPath) 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 { try {
val filter = Filter().identifier(giftId.toBech32()) val filter = Filter().identifier(giftId.toBech32())
val event = client?.database()?.query(filter)?.first() val event = client?.database()?.query(filter)?.first()
@@ -223,20 +225,22 @@ class Nostr {
return null return null
} }
suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
if (rumor.id() == null) return if (rumor.id() == null) return
try { try {
val rngKeys = Keys.generate()
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 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(event)
client?.database()?.saveEvent(rumor.signWithKeys(rngKeys))
} catch (e: Exception) { } catch (e: Exception) {
// TODO: log error // 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 if (event.kind().asStd() != KindStandard.GIFT_WRAP) return null
// Check if the rumor is already cached // Check if the rumor is already cached
@@ -266,7 +270,7 @@ class Nostr {
return null return null
} }
fun conversationId(rumor: UnsignedEvent): Long { private fun conversationId(rumor: UnsignedEvent): Long {
val pubkeys: MutableList<PublicKey> = rumor.tags().publicKeys().toMutableList() val pubkeys: MutableList<PublicKey> = rumor.tags().publicKeys().toMutableList()
pubkeys.add(rumor.author()) pubkeys.add(rumor.author())
@@ -278,7 +282,8 @@ class Nostr {
return uniqueSortedKeys.hashCode().toLong() return uniqueSortedKeys.hashCode().toLong()
} }
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>(
RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ, RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ,
@@ -302,7 +307,7 @@ class Nostr {
return relayList return relayList
} }
suspend fun getMsgRelayList(): List<RelayUrl> { private suspend fun getMsgRelayList(): List<RelayUrl> {
// Construct a list of messaging relays // Construct a list of messaging relays
val msgRelayList = listOf( val msgRelayList = listOf(
RelayUrl.parse("wss://relay.0xchat.com"), RelayUrl.parse("wss://relay.0xchat.com"),
@@ -344,4 +349,67 @@ class Nostr {
val contactListEvent = EventBuilder.contactList(defaultContact).sign(signer!!) val contactListEvent = EventBuilder.contactList(defaultContact).sign(signer!!)
client?.sendEventNoWait(contactListEvent) 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
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 su.reya.coop.storage.SecretStorage import su.reya.coop.storage.SecretStorage
import kotlin.time.Clock
import kotlin.time.Duration import kotlin.time.Duration
class NostrViewModel( class NostrViewModel(
@@ -26,11 +29,61 @@ class NostrViewModel(
private val _isCreating = MutableStateFlow(false) private val _isCreating = MutableStateFlow(false)
val isCreating = _isCreating.asStateFlow() 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 _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?> { 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) { fun updateMetadata(pubkey: PublicKey, metadata: Metadata) {
@@ -42,17 +95,32 @@ class NostrViewModel(
try { try {
// Initialize nostr client // Initialize nostr client
nostr.init(dbPath) nostr.init(dbPath)
// Connect to bootstrap relays // Connect to bootstrap relays
nostr.connect() nostr.connect()
// Get user's secret
getUserSecret()
} catch (e: Exception) {
println("Failed to connect: ${e.message}")
}
}
}
fun startNotificationHandler() {
viewModelScope.launch {
nostr.handleNotifications { pubkey, metadata ->
updateMetadata(pubkey, metadata)
}
}
}
suspend fun getUserSecret() {
// Get user's signer secret // Get user's signer secret
val secret = secretStore.get("user_signer") val secret = secretStore.get("user_signer")
// If no secret is found, show onboarding screen // If no secret is found, show onboarding screen
if (secret == null) { if (secret == null) {
_hasSecret.value = false _hasSecret.value = false
return@launch return
} }
_hasSecret.value = true _hasSecret.value = true
@@ -73,18 +141,6 @@ class NostrViewModel(
} else { } else {
throw IllegalArgumentException("Invalid secret format: $secret") throw IllegalArgumentException("Invalid secret format: $secret")
} }
} catch (e: Exception) {
println("Failed to connect: ${e.message}")
}
}
}
fun startNotificationHandler() {
viewModelScope.launch {
nostr.handleNotifications { pubkey, metadata ->
updateMetadata(pubkey, metadata)
}
}
} }
suspend fun getOrInitAppKeys(): Keys { suspend fun getOrInitAppKeys(): Keys {
@@ -123,6 +179,16 @@ class NostrViewModel(
// TODO: Implement import // 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() { override fun onCleared() {
super.onCleared() super.onCleared()
// Ensure all relays are disconnect // 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()
}