add get chat rooms
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
76
shared/src/commonMain/kotlin/su/reya/coop/Room.kt
Normal file
76
shared/src/commonMain/kotlin/su/reya/coop/Room.kt
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user