From 38f8e5bf418823ff38b960ab3d51dc38de63378e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 29 Jun 2026 10:57:07 +0700 Subject: [PATCH 1/3] split nostr class --- .../kotlin/su/reya/coop/MainActivity.kt | 1 + .../su/reya/coop/NostrForegroundService.kt | 7 +- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 1061 ----------------- .../kotlin/su/reya/coop/NostrViewModel.kt | 73 +- .../kotlin/su/reya/coop/nostr/Messaging.kt | 340 ++++++ .../kotlin/su/reya/coop/nostr/Nostr.kt | 266 +++++ .../su/reya/coop/nostr/ProfileManager.kt | 360 ++++++ .../kotlin/su/reya/coop/nostr/RelayManager.kt | 183 +++ 8 files changed, 1191 insertions(+), 1100 deletions(-) delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index 0fb8442..69ac4ad 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -14,6 +14,7 @@ import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import su.reya.coop.coop.storage.SecretStore +import su.reya.coop.nostr.NostrManager import kotlin.system.exitProcess class MainActivity : ComponentActivity() { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt index 82bcf57..76ebe5b 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import su.reya.coop.nostr.NostrManager import java.io.File private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES" @@ -65,17 +66,17 @@ class NostrForegroundService : Service() { } catch (e: Exception) { throw IllegalStateException("Failed to initialize Nostr Client", e) } - + // Connect to bootstrap relays nostr.connectBootstrapRelays() // Handle notifications nostr.handleNotifications( onMetadataUpdate = { pubkey, metadata -> - serviceScope.launch { nostr.emitMetadataUpdate(pubkey, metadata) } + serviceScope.launch { nostr.profiles.emitMetadataUpdate(pubkey, metadata) } }, onContactListUpdate = { contacts -> - serviceScope.launch { nostr.emitContactListUpdate(contacts) } + serviceScope.launch { nostr.profiles.emitContactListUpdate(contacts) } }, onNewMessage = { event -> serviceScope.launch { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt deleted file mode 100644 index 9ec72f5..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ /dev/null @@ -1,1061 +0,0 @@ -package su.reya.coop - -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.request.get -import io.ktor.client.statement.HttpResponse -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.supervisorScope -import rust.nostr.sdk.AckPolicy -import rust.nostr.sdk.Alphabet -import rust.nostr.sdk.AsyncNostrSigner -import rust.nostr.sdk.Client -import rust.nostr.sdk.ClientBuilder -import rust.nostr.sdk.ClientNotification -import rust.nostr.sdk.Contact -import rust.nostr.sdk.Event -import rust.nostr.sdk.EventBuilder -import rust.nostr.sdk.EventId -import rust.nostr.sdk.Filter -import rust.nostr.sdk.GossipConfig -import rust.nostr.sdk.Keys -import rust.nostr.sdk.Kind -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 -import rust.nostr.sdk.RelayCapabilities -import rust.nostr.sdk.RelayMessageEnum -import rust.nostr.sdk.RelayMetadata -import rust.nostr.sdk.RelayStatus -import rust.nostr.sdk.RelayUrl -import rust.nostr.sdk.ReqExitPolicy -import rust.nostr.sdk.ReqTarget -import rust.nostr.sdk.SendEventTarget -import rust.nostr.sdk.SignerAuthenticator -import rust.nostr.sdk.SingleLetterTag -import rust.nostr.sdk.SleepWhenIdle -import rust.nostr.sdk.SubscribeAutoCloseOptions -import rust.nostr.sdk.Tag -import rust.nostr.sdk.Timestamp -import rust.nostr.sdk.UnsignedEvent -import rust.nostr.sdk.extractRelayList -import rust.nostr.sdk.initLogger -import rust.nostr.sdk.nip17ExtractRelayList -import rust.nostr.sdk.nip59MakeGiftWrapAsync -import kotlin.time.Duration - -object NostrManager { - val instance = Nostr() - - val BOOTSTRAP_RELAYS = listOf( - "wss://relay.primal.net", - "wss://relay.ditto.pub", - "wss://user.kindpag.es", - ) - - val INDEXER_RELAY = listOf( - "wss://indexer.coracle.social", - ) - - val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY -} - -class Nostr { - var client: Client? = null - private set - var signer: UniversalSigner = UniversalSigner(Keys.generate()) - private set - var sentEvents: MutableMap> = mutableMapOf() - private set - var rumorMap: MutableMap = mutableMapOf() - private set - - private val isInitialized = MutableStateFlow(false) - - private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) - val newEvents = _newEvents.asSharedFlow() - - private val _metadataUpdates = - MutableSharedFlow>(extraBufferCapacity = 100) - val metadataUpdates = _metadataUpdates.asSharedFlow() - - private val _contactListUpdates = MutableSharedFlow>(extraBufferCapacity = 100) - val contactListUpdates = _contactListUpdates.asSharedFlow() - - private val _messageSyncState = MutableStateFlow(MessageSyncState()) - val messageSyncState = _messageSyncState.asStateFlow() - - suspend fun emitNewEvent(event: UnsignedEvent) { - _newEvents.emit(event) - } - - suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) { - _metadataUpdates.emit(pubkey to metadata) - } - - suspend fun emitContactListUpdate(contacts: List) { - _contactListUpdates.emit(contacts) - } - - suspend fun init( - dbPath: String, - logLevel: LogLevel = LogLevel.WARN - ) { - try { - if (isInitialized.value) return - - // Initialize the logger for nostr client - initLogger(logLevel) - - // Initialize configurations for nostr client - val lmdb = NostrDatabase.lmdb(dbPath) - val gossip = NostrGossip.inMemory() - val authenticator = SignerAuthenticator(signer) - val idleTimeout = Duration.parse("5m") - - client = - ClientBuilder() - .authenticator(authenticator) - .database(lmdb) - .gossip(gossip) - .gossipConfig( - GossipConfig() - .noBackgroundRefresh() - .fetchTimeout(Duration.parse("2s")) - ) - .verifySubscriptions(false) - .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) - .build() - - isInitialized.value = true - } catch (e: Exception) { - throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e) - } - } - - suspend fun waitUntilInitialized() { - isInitialized.first { it } - } - - suspend fun connectBootstrapRelays() { - NostrManager.BOOTSTRAP_RELAYS.forEach { url -> - client?.addRelay(RelayUrl.parse(url)) - } - NostrManager.INDEXER_RELAY.forEach { url -> - client?.addRelay( - url = RelayUrl.parse(url), - capabilities = RelayCapabilities.gossip() - ) - } - // Connect to all bootstrap relays - client?.connect() - } - - suspend fun reconnect() { - NostrManager.ALL_RELAYS.forEach { url -> - try { - client?.relay(RelayUrl.parse(url)).let { relay -> - if (relay != null) { - if (relay.status() != RelayStatus.CONNECTED) { - relay.connect() - } - } - } - } catch (e: Exception) { - println("Failed to reconnect relay: ${e.message}") - } - } - } - - suspend fun disconnect() { - NostrManager.ALL_RELAYS.forEach { url -> - try { - client?.disconnectRelay(RelayUrl.parse(url)) - } catch (e: Exception) { - println("Failed to disconnect relay: ${e.message}") - } - } - } - - suspend fun prune() { - try { - client?.database()?.wipe() - } catch (e: Exception) { - throw IllegalStateException("Failed to prune database: ${e.message}", e) - } - } - - suspend fun setSigner(new: AsyncNostrSigner) { - try { - signer.switch(new) - } catch (e: Exception) { - throw IllegalStateException("Failed to set signer: ${e.message}", e) - } - } - - fun isSignedByUser(event: Event): Boolean { - return try { - signer.currentUser == event.author() - } catch (e: Exception) { - println("Failed to check if event is signed by user: ${e.message}") - false - } - } - - suspend fun getUserMetadata() { - try { - val author = signer.currentUser ?: throw IllegalStateException("User not signed in") - - // Get the latest metadata event - val metadataFilter = - Filter().kind(Kind.fromStd(KindStandard.METADATA)).author(author).limit(1u) - - // Get the latest contact list event - val contactFilter = - Filter().kind(Kind.fromStd(KindStandard.CONTACT_LIST)).author(author).limit(1u) - - // Get the latest messaging relay list event - val msgRelayFilter = - Filter().kind(Kind.fromStd(KindStandard.INBOX_RELAYS)).author(author).limit(1u) - - // Construct a target that includes all filters - val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter)) - val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) - - client?.subscribe(target = target, id = "user-metadata", closeOn = opts) - } catch (e: Exception) { - throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e) - } - } - - suspend fun getUserMessages(msgRelayList: Event) { - try { - val author = signer.currentUser ?: throw IllegalStateException("User not signed in") - val relays = nip17ExtractRelayList(msgRelayList) - - // Ensure relay connections - relays.forEach { relay -> - client?.addRelay(relay) - client?.connectRelay(relay) - } - - // Construct a filter for gift wrap events - val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(author) - val target = mutableMapOf>() - relays.forEach { relay -> - target[relay] = listOf(filter) - } - - client?.subscribe( - target = ReqTarget.manual(target), - id = "gift-wraps" - ) - } catch (e: Exception) { - throw IllegalStateException("Failed to fetch user messages: ${e.message}", e) - } - } - - @OptIn(ExperimentalCoroutinesApi::class) - suspend fun handleNotifications( - onMetadataUpdate: (PublicKey, Metadata) -> Unit, - onContactListUpdate: (List) -> Unit, - onNewMessage: (UnsignedEvent) -> Unit, - ) = supervisorScope { - val now = Timestamp.now() - val processedEvent = mutableSetOf() - val notifications = client?.notifications() ?: return@supervisorScope - - val giftWrapQueue = Channel(Channel.UNLIMITED) - var processedCount = 0 - var eoseReceived = false - - launch(Dispatchers.Default) { - for (event in giftWrapQueue) { - val rumor = extractRumor(event) - processedCount++ - - // Trigger new message notification - if (rumor != null) { - if (rumor.createdAt().asSecs() >= now.asSecs()) { - onNewMessage(rumor) - } - } - - // Update sync state - _messageSyncState.update { - it.copy( - processedCount = processedCount, - isSyncing = !eoseReceived || !giftWrapQueue.isEmpty - ) - } - } - } - - while (true) { - val notification = notifications.next() ?: continue - - when (notification) { - is ClientNotification.Message -> { - val relayUrl = notification.relayUrl - - when (val message = notification.message.asEnum()) { - is RelayMessageEnum.EventMsg -> { - val event = message.event - - // Prevent processing duplicate events - if (processedEvent.contains(event.id())) continue - processedEvent.add(event.id()) - - when (event.kind().asStd()) { - KindStandard.METADATA -> { - try { - val metadata = Metadata.fromJson(event.content()) - onMetadataUpdate(event.author(), metadata) - } catch (e: Exception) { - println("Failed to parse metadata: $e") - } - } - - KindStandard.CONTACT_LIST -> { - if (isSignedByUser(event = event)) { - val pubkeys = event.tags().publicKeys() - // Get mutual contacts - getMutualContacts(pubkeys) - // Emit contact list update - onContactListUpdate(pubkeys) - } - } - - KindStandard.INBOX_RELAYS -> { - // Get all gift wrap events for the current user - if (isSignedByUser(event = event)) { - getUserMessages(msgRelayList = event) - } - } - - KindStandard.GIFT_WRAP -> { - giftWrapQueue.send(event) - } - - else -> {} - } - } - - is RelayMessageEnum.EndOfStoredEvents -> { - if (message.subscriptionId == "gift-wraps") { - eoseReceived = true - if (giftWrapQueue.isEmpty) { - _messageSyncState.update { it.copy(isSyncing = false) } - } - } - } - - is RelayMessageEnum.Ok -> { - if (sentEvents.containsKey(message.eventId)) { - val currentRelays = sentEvents[message.eventId] ?: emptyList() - sentEvents[message.eventId] = currentRelays + relayUrl - } - } - - else -> { - /* Ignore other message types */ - } - } - } - - is ClientNotification.Shutdown -> { - break - } - - else -> { - /* Ignore other message types */ - } - } - } - } - - private suspend fun getMutualContacts(pubkeys: List) { - try { - val kind = Kind.fromStd(KindStandard.CONTACT_LIST) - val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong()) - val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) - - val target = mutableMapOf>() - NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> - target[RelayUrl.parse(relay)] = listOf(filter) - } - - client?.subscribe( - target = ReqTarget.manual(target), - id = "mutual-contacts", - closeOn = opts, - ) - } catch (e: Exception) { - throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e) - } - } - - private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? { - try { - val filter = Filter().identifier(giftId.toHex()) - val event = client?.database()?.query(filter)?.first() - - return event?.content()?.let { UnsignedEvent.fromJson(it) } - } catch (e: Throwable) { - throw IllegalStateException("Failed to get cached rumor: ${e.message}", e) - } - } - - private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { - try { - // Construct reference tags - val tags = listOf( - Tag.identifier(giftId.toHex()), - Tag.publicKey(rumor.author()), - Tag.custom("r", listOf(rumor.roomId().toString())), - Tag.custom("k", listOf("14")) - ) - - // Set event kind - val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) - - // Construct event - val event = EventBuilder(kind, rumor.asJson()) - .tags(tags) - .finalizeAsync(Keys.generate()) - - client?.database()?.saveEvent(event) - } catch (e: Throwable) { - println("Failed to set cached rumor: ${e.message}") - } - } - - private suspend fun extractRumor(event: Event): UnsignedEvent? { - try { - // Gift wrap must have at least one 'p' tag - if (event.tags().publicKeys().isEmpty()) { - println("No recipient tags found.") - return null - } - - // Event must be a gift wrap - if (event.kind().asStd().let { it != KindStandard.GIFT_WRAP }) { - println("Event is not a gift wrap.") - return null - } - - // Check if the rumor is already cached - val cachedRumor = getCachedRumor(event.id()) - if (cachedRumor != null) return cachedRumor - - // Decrypt the gift wrap event - val seal = signer.nip44DecryptAsync(event.author(), event.content()) - val sealEvent = Event.fromJson(seal) - - // Verify seal event - if (!sealEvent.verify()) { - println("Failed to verify seal event.") - return null - } - - // Decrypt the rumor - val rumor = signer.nip44DecryptAsync(sealEvent.author(), sealEvent.content()) - val unsignedEvent = UnsignedEvent.fromJson(rumor).ensureId() - - // Ensure the rumor author matches the seal - if (unsignedEvent.author() != sealEvent.author()) { - println("Author mismatch.") - return null - } - - // Cache the rumor for later use - setCachedRumor(event.id(), unsignedEvent) - - return unsignedEvent - } catch (e: Throwable) { - println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}") - return null - } - } - - private suspend fun getDefaultRelayList(): Map { - // Construct a list of relays - val relayList = mapOf( - RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ, - RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ, - RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE, - RelayUrl.parse("wss://nostr.superfriends.online") to RelayMetadata.WRITE - ) - - // Ensure all relays are added and connected - relayList.forEach { (relay, metadata) -> - client?.addRelay( - url = relay, - capabilities = - when (metadata) { - RelayMetadata.READ -> RelayCapabilities.read() - RelayMetadata.WRITE -> RelayCapabilities.write() - } - ) - client?.connectRelay(relay) - } - - return relayList - } - - suspend fun getDefaultMsgRelayList(): List { - // Construct a list of messaging relays - val msgRelayList = listOf( - RelayUrl.parse("wss://auth.nostr1.com"), - RelayUrl.parse("wss://nip17.com"), - ) - - // Ensure all relays are added and connected - msgRelayList.forEach { relay -> - client?.addRelay(relay, RelayCapabilities.none()) - client?.connectRelay(relay) - } - - return msgRelayList - } - - suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) { - // Send relay list event - val relayList = getDefaultRelayList() - val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys) - - client?.sendEvent( - event = relayListEvent, - target = SendEventTarget.broadcast(), - ackPolicy = AckPolicy.all(), - okTimeout = Duration.parse("3s") - ) - - // Send messaging relay list event - val msgRelayList = getDefaultMsgRelayList() - val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).finalizeAsync(keys) - - client?.sendEvent( - event = msgRelayListEvent, - target = SendEventTarget.toNip65(), - ackPolicy = AckPolicy.none() - ) - - // Send metadata event - val metadata = - Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture)) - val metadataEvent = EventBuilder.metadata(metadata).finalizeAsync(keys) - - client?.sendEvent( - event = metadataEvent, - target = SendEventTarget.broadcast(), - ackPolicy = AckPolicy.none() - ) - - // Send contact list event - val defaultContact = - Contact(PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x")) - val contactListEvent = EventBuilder.contactList(listOf(defaultContact)).finalizeAsync(keys) - - client?.sendEvent( - event = contactListEvent, - target = SendEventTarget.toNip65(), - ackPolicy = AckPolicy.none() - ) - - setSigner(keys) - } - - suspend fun updateProfile( - name: String? = null, - bio: String? = null, - picture: String? = null - ): Metadata { - val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in") - - try { - val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord() - val newRecord = record.copy( - displayName = name ?: record.displayName, - about = bio ?: record.about, - picture = picture ?: record.picture - ) - val newMetadata = Metadata.fromRecord(newRecord) - val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer) - - client?.sendEvent( - event = event, - target = SendEventTarget.broadcast(), - ackPolicy = AckPolicy.none() - ) - - return newMetadata - } catch (e: Exception) { - throw IllegalStateException("Failed to update identity: ${e.message}", e) - } - } - - private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? { - return try { - val kind = Kind.fromStd(KindStandard.METADATA) - val filter = Filter().kind(kind).author(pubkey).limit(1u) - val event = client?.database()?.query(filter)?.first() ?: return null - - Metadata.fromJson(event.content()) - } catch (e: Exception) { - println("Failed to get latest metadata: ${e.message}") - null - } - } - - suspend fun getAllCacheMetadata(): Map { - try { - val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(100u) - val events = client?.database()?.query(filter) - val results = mutableMapOf() - - events?.toVec()?.forEach { event -> - try { - val metadata = Metadata.fromJson(event.content()) - results[event.author()] = metadata - } catch (e: Exception) { - println("Failed to parse metadata: $e") - } - } - - return results - } catch (e: Exception) { - println("Failed to get all cache metadata: ${e.message}") - return emptyMap() - } - } - - suspend fun fetchMetadataBatch(keys: List) { - try { - val limit = keys.size.toULong() * 2u - val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) - - // Construct a filter for metadata events - val filter = Filter() - .kind(Kind.fromStd(KindStandard.METADATA)) - .authors(keys) - .limit(limit) - - // Construct request target - val target = mutableMapOf>() - NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> - target[RelayUrl.parse(relay)] = listOf(filter) - } - - client?.subscribe(target = ReqTarget.manual(target), closeOn = opts) - } catch (e: Exception) { - throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e) - } - } - - suspend fun setMsgRelays(urls: List) { - try { - - - val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer) - - client?.sendEvent( - event = event, - target = SendEventTarget.toNip65(), - ackPolicy = AckPolicy.none(), - ) - - val currentUser = - signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") - - val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) - val filter = Filter().kind(kind).author(currentUser).limit(1u) - - val target = ReqTarget.auto(listOf(filter)) - val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) - - client?.subscribe(target = target, closeOn = opts) - } catch (e: Exception) { - throw IllegalStateException("Failed to set msg relays: ${e.message}", e) - } - } - - suspend fun getMsgRelays(publicKey: PublicKey): List { - try { - val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) - val filter = Filter().kind(kind).author(publicKey).limit(1u) - val events = client?.database()?.query(filter) - - return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList()) - } catch (e: Exception) { - throw IllegalStateException("Failed to get msg relays: ${e.message}", e) - } - } - - suspend fun fetchMsgRelays(publicKey: PublicKey): List { - try { - val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) - val filter = Filter().kind(kind).author(publicKey).limit(1u) - val target = ReqTarget.auto(listOf(filter)) - val events = client?.fetchEvents(target, timeout = Duration.parse("3s")) - - return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList()) - } catch (e: Exception) { - throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e) - } - } - - suspend fun getRelayList(publicKey: PublicKey): Map { - try { - val kind = Kind.fromStd(KindStandard.RELAY_LIST) - val filter = Filter().kind(kind).author(publicKey).limit(1u) - val events = client?.database()?.query(filter) - - return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap()) - } catch (e: Exception) { - throw IllegalStateException("Failed to get relay list: ${e.message}", e) - } - } - - suspend fun setRelaylist(relays: Map) { - try { - val event = EventBuilder.relayList(relays).finalizeAsync(signer) - - client?.sendEvent( - event = event, - target = SendEventTarget.broadcast(), - ackPolicy = AckPolicy.none(), - ) - } catch (e: Exception) { - throw IllegalStateException("Failed to set msg relays: ${e.message}", e) - } - } - - suspend fun setContactList(contacts: List) { - try { - val contacts = contacts.map { Contact(it) } - val event = EventBuilder.contactList(contacts).finalizeAsync(signer) - - client?.sendEvent( - event = event, - target = SendEventTarget.broadcast(), - ackPolicy = AckPolicy.none(), - ) - } catch (e: Exception) { - throw IllegalStateException("Failed to set contact list: ${e.message}", e) - } - } - - suspend fun getChatRooms(): Set? { - try { - val userPubkey = - signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") - - val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) - val kTag = SingleLetterTag.lowercase(Alphabet.K) - - // Get all DM events - val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm")) - val events = client?.database()?.query(filter) - - // Collect rooms - val roomsMap: MutableMap = mutableMapOf() - - events - ?.toVec() - ?.map { UnsignedEvent.fromJson(it.content()) } - ?.filter { it.tags().publicKeys().isNotEmpty() } - ?.forEach { event -> - val newRoom = Room.new(rumor = event, userPubkey = userPubkey) - val existingRoom = roomsMap[newRoom.id] - - // Check if the room already exists - if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) { - val rTag = SingleLetterTag.lowercase(Alphabet.R) - val filter = Filter().kind(kind).pubkey(userPubkey) - .customTag(rTag, newRoom.id.toString()) - - // Determine if it's an ongoing room - val isOngoing = - client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false - - // Append room to map - roomsMap[newRoom.id] = - if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom - } - } - - return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet() - } catch (e: Exception) { - println("Failed to get chat rooms: ${e.message}") - return null - } - } - - suspend fun getChatRoomMessages(roomId: Long): List { - try { - val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) - val filter = Filter().kind(kind).reference(roomId.toString()) - val events = client?.database()?.query(filter) - - // Merge the events - return events - ?.toVec() - ?.map { UnsignedEvent.fromJson(it.content()).ensureId() } - // Filter out events without public keys (receivers) - ?.filter { it.tags().publicKeys().isNotEmpty() } - ?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList() - } catch (e: Exception) { - throw IllegalStateException("Failed to get chat room messages: ${e.message}", e) - } - } - - suspend fun chatRoomConnect(members: List) { - try { - members.forEach { member -> - val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) - val filter = Filter().kind(kind).author(member).limit(1u) - - val stream = client?.streamEvents( - target = ReqTarget.auto(listOf(filter)), - id = null, - timeout = Duration.parse("3s"), - policy = ReqExitPolicy.ExitOnEose - ) - - stream?.next()?.let { res -> - val event = res.event ?: return@let - connectMsgRelays(event) - } - } - } catch (e: Exception) { - throw IllegalStateException("Failed to fetch relays: ${e.message}", e) - } - } - - suspend fun connectMsgRelays(event: Event) { - try { - val urls = nip17ExtractRelayList(event) - for (url in urls) { - client?.addRelay(url, RelayCapabilities.gossip()) - client?.connectRelay(url) - } - } catch (e: Exception) { - throw IllegalStateException("Failed to connect to relays: ${e.message}", e) - } - } - - suspend fun sendMessage( - to: Set, - content: String, - subject: String? = null, - replies: List = emptyList(), - onRumorCreated: ((UnsignedEvent) -> Unit)? = null, - ) { - try { - val currentUser = - signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") - - val tags = mutableListOf() - - // Add a subject tag if provided - if (subject != null) { - tags.add(Tag.custom("subject", listOf(subject))) - } - - // Add event tags for replies - if (replies.isNotEmpty()) { - replies.forEach { replyId -> - tags.add(Tag.event(replyId)) - } - } - - // Add public key tags for each recipient - to.forEach { pubkey -> - tags.add(Tag.publicKey(pubkey)) - } - - for (receiver in setOf(currentUser) + to) { - // Construct the rumor event - // NEVER SIGN this event with the current user signer - val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), content) - .tags(tags) - .finalizeUnsigned(currentUser) - .ensureId() - - // Emit the rumor to the chat screen - if (receiver == currentUser) { - onRumorCreated?.invoke(rumor) - } - - // Construct the gift wrap event - val gift = nip59MakeGiftWrapAsync( - signer = signer, - receiverPubkey = receiver, - rumor = rumor, - extraTags = listOf( - Tag.custom("k", listOf("14")) - ) - ) - - // Send the event to receiver's NIP-17 relays - val output = client?.sendEvent( - event = gift, - target = SendEventTarget.toNip17(), - ackPolicy = AckPolicy.none(), - authenticationTimeout = Duration.parse("2s") - ) - - if (output != null) { - // Keep track of sent events - sentEvents[output.id] = emptyList() - - // Keep track of rumor IDs - val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null") - rumorMap[id] = output.id - - // Collect failed outputs - output.failed.forEach { (relayUrl, reason) -> - println("Failed to send event to relay $relayUrl: $reason") - } - } - } - } catch (e: Exception) { - 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): PublicKey { - try { - val address = Nip05Address.parse(query) - val profile = profileFromAddress(HttpClient(), address) - - return profile.publicKey() - } catch (e: Exception) { - throw IllegalStateException("Failed to search address: ${e.message}", e) - } - } - - suspend fun searchByNostr(query: String): List { - try { - // Add search relay - val searchRelay = RelayUrl.parse("wss://antiprimal.net") - if (client?.relay(searchRelay) == null) { - client?.addRelay(url = searchRelay, capabilities = RelayCapabilities.read()) - client?.connectRelay(searchRelay) - } - - val kinds = listOf(Kind.fromStd(KindStandard.METADATA)) - val filter = Filter().kinds(kinds).search(query).limit(10u) - val target = ReqTarget.manual(mapOf(searchRelay to listOf(filter))) - - val stream = client?.streamEvents( - target = target, - id = "search", - timeout = Duration.parse("3s"), - policy = ReqExitPolicy.ExitOnEose - ) - - // Collect the results - val results = mutableListOf() - - // Keep searching until the stream is closed or timeout - stream?.next()?.let { event -> - val event = event.event ?: return@let - results.add(event.author()) - } - - return results - } catch (e: Exception) { - throw IllegalStateException("Failed to search nostr: ${e.message}", e) - } - } - - suspend fun verifyActivity(pubkey: PublicKey): Timestamp? { - try { - val filter = Filter().author(pubkey).limit(3u) - val target = mutableMapOf>() - NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> - target[RelayUrl.parse(relay)] = listOf(filter) - } - - val events = client?.fetchEvents( - target = ReqTarget.manual(target), - timeout = Duration.parse("3s") - ) - - return events?.first()?.createdAt() - } catch (e: Exception) { - throw IllegalStateException("Failed to get latest activity: ${e.message}", e) - } - } - - suspend fun verifyContact(pubkey: PublicKey): Boolean { - try { - val currentUser = - signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") - - val kind = Kind.fromStd(KindStandard.CONTACT_LIST) - val filter = Filter().kind(kind).author(currentUser).limit(1u) - - val events = client?.database()?.query(filter) - val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf() - - return pubkeys.contains(pubkey) - } catch (e: Exception) { - throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e) - } - } - - suspend fun mutualContacts(pubkey: PublicKey): Set { - try { - val currentUser = - signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") - - val kind = Kind.fromStd(KindStandard.CONTACT_LIST) - val filter = Filter().kind(kind).pubkey(pubkey).limit(1u) - - val events = client?.database()?.query(filter) - val contacts = mutableSetOf() - - events?.toVec()?.filter { it.author() != currentUser }?.forEach { event -> - contacts.add(event.author()) - } - - return contacts.toSet() - } catch (e: Exception) { - throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e) - } - } -} - -data class MessageSyncState( - val processedCount: Int = 0, - val isSyncing: Boolean = false -) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 87e01a5..0452ce6 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -41,6 +41,7 @@ import rust.nostr.sdk.Tag import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent import su.reya.coop.blossom.BlossomClient +import su.reya.coop.nostr.Nostr import su.reya.coop.storage.SecretStorage import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds @@ -87,7 +88,7 @@ class NostrViewModel( private val seenPublicKeys = mutableSetOf() - val isSyncing = nostr.messageSyncState.map { it.isSyncing } + val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) init { @@ -157,7 +158,7 @@ class NostrViewModel( private suspend fun runObserver() = coroutineScope { // Observe message sync progress launch { - nostr.messageSyncState.collect { state -> + nostr.messages.messageSyncState.collect { state -> // When at least some messages are processed, allow UI to show the list if (state.processedCount > 0) { _isPartialProcessedGiftWrap.value = true @@ -192,14 +193,14 @@ class NostrViewModel( // Observe contact list updates launch { - nostr.contactListUpdates.collect { contacts -> + nostr.profiles.contactListUpdates.collect { contacts -> _contactList.value = contacts.toSet() } } // Observe metadata updates launch { - nostr.metadataUpdates.collect { (pubkey, metadata) -> + nostr.profiles.metadataUpdates.collect { (pubkey, metadata) -> updateMetadata(pubkey, metadata) } } @@ -237,7 +238,7 @@ class NostrViewModel( val keysToRequest = batch.toList() batch.clear() - nostr.fetchMetadataBatch(keysToRequest) + nostr.profiles.fetchMetadataBatch(keysToRequest) } } } @@ -248,7 +249,7 @@ class NostrViewModel( // Wait until the client is ready nostr.waitUntilInitialized() - val results = nostr.getAllCacheMetadata() + val results = nostr.profiles.getAllCacheMetadata() results.forEach { (pubkey, metadata) -> // Update the metadata state updateMetadata(pubkey, metadata) @@ -293,20 +294,20 @@ class NostrViewModel( if (pubkey != null) { // Get chat rooms - val rooms = nostr.getChatRooms() ?: emptySet() + val rooms = nostr.messages.getChatRooms() ?: emptySet() if (rooms.isNotEmpty()) { mergeChatRooms(rooms) _isPartialProcessedGiftWrap.value = true } // Get all metadata for the current user - nostr.getUserMetadata() + nostr.profiles.getUserMetadata() // Small delay to ensure all relays are connected delay(2.seconds) // Check if the relay list is empty - val relays = nostr.getMsgRelays(pubkey) + val relays = nostr.relays.getMsgRelays(pubkey) if (relays.isEmpty()) _isRelayListEmpty.value = true break @@ -435,7 +436,7 @@ class NostrViewModel( _isBusy.value = true try { val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") } - val newMetadata = nostr.updateProfile(name, bio, avatarUrl) + val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl) val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") // Update the metadata state after successfully published updateMetadata(currentUser, newMetadata) @@ -460,7 +461,7 @@ class NostrViewModel( try { val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") } // Create identity - nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl) + nostr.profiles.createIdentity(keys = keys, name = name, bio, picture = avatarUrl) // Persist the secret in the secret storage secretStore.set("user_signer", secret) // Update local states @@ -567,14 +568,14 @@ class NostrViewModel( } suspend fun refetchMsgRelays(pubkey: PublicKey) { - val relays = nostr.fetchMsgRelays(pubkey) + val relays = nostr.relays.fetchMsgRelays(pubkey) if (relays.isNotEmpty()) dismissRelayWarning() } suspend fun useDefaultMsgRelayList() { try { - val defaultRelays = nostr.getDefaultMsgRelayList() - nostr.setMsgRelays(defaultRelays) + val defaultRelays = nostr.relays.getDefaultMsgRelayList() + nostr.relays.setMsgRelays(defaultRelays) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -583,7 +584,7 @@ class NostrViewModel( suspend fun currentUserRelayList(): Map { try { val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - return nostr.getRelayList(currentUser) + return nostr.relays.getRelayList(currentUser) } catch (e: Exception) { showError("Error: ${e.message}") return emptyMap() @@ -596,7 +597,7 @@ class NostrViewModel( val relays = currentUserRelayList().toMutableMap() relays[relayUrl] = RelayMetadata.WRITE - nostr.setRelaylist(relays) + nostr.relays.setRelaylist(relays) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -608,7 +609,7 @@ class NostrViewModel( val relays = currentUserRelayList().toMutableMap() relays[relayUrl] = RelayMetadata.READ - nostr.setRelaylist(relays) + nostr.relays.setRelaylist(relays) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -620,7 +621,7 @@ class NostrViewModel( val relays = currentUserRelayList().toMutableMap() relays.remove(relayUrl) - nostr.setRelaylist(relays) + nostr.relays.setRelaylist(relays) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -629,7 +630,7 @@ class NostrViewModel( suspend fun currentUserMsgRelayList(): List { try { val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - return nostr.getMsgRelays(currentUser) + return nostr.relays.getMsgRelays(currentUser) } catch (e: Exception) { showError("Error: ${e.message}") return emptyList() @@ -642,7 +643,7 @@ class NostrViewModel( val relays = currentUserMsgRelayList().toMutableSet() relays.add(relayUrl) - nostr.setMsgRelays(relays.toList()) + nostr.relays.setMsgRelays(relays.toList()) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -654,7 +655,7 @@ class NostrViewModel( val relays = currentUserMsgRelayList().toMutableSet() relays.remove(relayUrl) - nostr.setMsgRelays(relays.toList()) + nostr.relays.setMsgRelays(relays.toList()) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -666,7 +667,7 @@ class NostrViewModel( try { val updated = contactList.value + publicKey // Publish new event - nostr.setContactList(updated.toList()) + nostr.profiles.setContactList(updated.toList()) // Optimistic local update _contactList.update { it + publicKey } } catch (e: Exception) { @@ -677,7 +678,7 @@ class NostrViewModel( suspend fun addContact(address: String): Boolean { val pubkey = try { if (address.contains("@")) { - nostr.searchByAddress(address) + nostr.profiles.searchByAddress(address) } else { PublicKey.parse(address) } @@ -699,7 +700,7 @@ class NostrViewModel( try { val updated = contactList.value - publicKey // Publish new event - nostr.setContactList(updated.toList()) + nostr.profiles.setContactList(updated.toList()) // Optimistic local update _contactList.update { it - publicKey } } catch (e: Exception) { @@ -761,14 +762,14 @@ class NostrViewModel( fun getChatRooms() { viewModelScope.launch { - val rooms = nostr.getChatRooms() ?: emptySet() + val rooms = nostr.messages.getChatRooms() ?: emptySet() mergeChatRooms(rooms) } } suspend fun refreshChatRooms() { try { - val rooms = nostr.getChatRooms() ?: emptySet() + val rooms = nostr.messages.getChatRooms() ?: emptySet() mergeChatRooms(rooms) } catch (e: Exception) { showError("Error: ${e.message}") @@ -777,7 +778,7 @@ class NostrViewModel( suspend fun getChatRoomMessages(roomId: Long): List { try { - return nostr.getChatRoomMessages(roomId) + return nostr.messages.getChatRoomMessages(roomId) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -791,7 +792,7 @@ class NostrViewModel( val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") val members = room.members - nostr.chatRoomConnect(members.toList()) + nostr.messages.chatRoomConnect(members.toList()) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -805,7 +806,7 @@ class NostrViewModel( viewModelScope.launch { try { val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") - nostr.sendMessage( + nostr.messages.sendMessage( to = room.members, content = message, subject = room.subject, @@ -822,10 +823,10 @@ class NostrViewModel( } fun isMessageSent(id: EventId): Boolean { - val giftWrapId = nostr.rumorMap[id] + val giftWrapId = nostr.messages.rumorMap[id] if (giftWrapId != null) { - val isSent = nostr.sentEvents[giftWrapId]?.isNotEmpty() ?: false + val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false return isSent } else { return false @@ -849,7 +850,7 @@ class NostrViewModel( suspend fun searchByAddress(query: String): PublicKey? { try { - return nostr.searchByAddress(query) + return nostr.profiles.searchByAddress(query) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -858,7 +859,7 @@ class NostrViewModel( suspend fun searchByNostr(query: String): List { try { - return nostr.searchByNostr(query) + return nostr.profiles.searchByNostr(query) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -867,7 +868,7 @@ class NostrViewModel( suspend fun verifyActivity(pubkey: PublicKey): Timestamp? { return try { - nostr.verifyActivity(pubkey) + nostr.profiles.verifyActivity(pubkey) } catch (e: Exception) { showError("Error: ${e.message}") null @@ -876,7 +877,7 @@ class NostrViewModel( suspend fun verifyContact(pubkey: PublicKey): Boolean { return try { - nostr.verifyContact(pubkey) + nostr.profiles.verifyContact(pubkey) } catch (e: Exception) { showError("Error: ${e.message}") false @@ -885,7 +886,7 @@ class NostrViewModel( suspend fun mutualContacts(pubkey: PublicKey): Set { return try { - nostr.mutualContacts(pubkey) + nostr.profiles.mutualContacts(pubkey) } catch (e: Exception) { showError("Error: ${e.message}") setOf() diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt new file mode 100644 index 0000000..f4a089f --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt @@ -0,0 +1,340 @@ +package su.reya.coop.nostr + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import rust.nostr.sdk.AckPolicy +import rust.nostr.sdk.Alphabet +import rust.nostr.sdk.Client +import rust.nostr.sdk.Event +import rust.nostr.sdk.EventBuilder +import rust.nostr.sdk.EventId +import rust.nostr.sdk.Filter +import rust.nostr.sdk.Keys +import rust.nostr.sdk.Kind +import rust.nostr.sdk.KindStandard +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayCapabilities +import rust.nostr.sdk.RelayUrl +import rust.nostr.sdk.ReqExitPolicy +import rust.nostr.sdk.ReqTarget +import rust.nostr.sdk.SendEventTarget +import rust.nostr.sdk.SingleLetterTag +import rust.nostr.sdk.Tag +import rust.nostr.sdk.UnsignedEvent +import rust.nostr.sdk.nip17ExtractRelayList +import rust.nostr.sdk.nip59MakeGiftWrapAsync +import su.reya.coop.Room +import su.reya.coop.RoomKind +import su.reya.coop.UniversalSigner +import su.reya.coop.roomId +import kotlin.time.Duration + +data class MessageSyncState( + val processedCount: Int = 0, + val isSyncing: Boolean = false +) + +class MessageManager(private val nostr: Nostr) { + private val client: Client? get() = nostr.client + private val signer: UniversalSigner get() = nostr.signer + + val sentEvents: MutableMap> = mutableMapOf() + val rumorMap: MutableMap = mutableMapOf() + + private val _messageSyncState = MutableStateFlow(MessageSyncState()) + val messageSyncState = _messageSyncState.asStateFlow() + + fun updateSyncState(update: (MessageSyncState) -> MessageSyncState) { + _messageSyncState.update(update) + } + + suspend fun getUserMessages(msgRelayList: Event) { + try { + val author = signer.currentUser ?: throw IllegalStateException("User not signed in") + val relays = nip17ExtractRelayList(msgRelayList) + + // Ensure relay connections + relays.forEach { relay -> + client?.addRelay(relay) + client?.connectRelay(relay) + } + + // Construct a filter for gift wrap events + val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(author) + val target = mutableMapOf>() + relays.forEach { relay -> + target[relay] = listOf(filter) + } + + client?.subscribe( + target = ReqTarget.manual(target), + id = "gift-wraps" + ) + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch user messages: ${e.message}", e) + } + } + + suspend fun extractRumor(event: Event): UnsignedEvent? { + try { + // Gift wrap must have at least one 'p' tag + if (event.tags().publicKeys().isEmpty()) { + println("No recipient tags found.") + return null + } + + // Event must be a gift wrap + if (event.kind().asStd().let { it != KindStandard.GIFT_WRAP }) { + println("Event is not a gift wrap.") + return null + } + + // Check if the rumor is already cached + val cachedRumor = getCachedRumor(event.id()) + if (cachedRumor != null) return cachedRumor + + // Decrypt the gift wrap event + val seal = signer.nip44DecryptAsync(event.author(), event.content()) + val sealEvent = Event.fromJson(seal) + + // Verify seal event + if (!sealEvent.verify()) { + println("Failed to verify seal event.") + return null + } + + // Decrypt the rumor + val rumor = signer.nip44DecryptAsync(sealEvent.author(), sealEvent.content()) + val unsignedEvent = UnsignedEvent.fromJson(rumor).ensureId() + + // Ensure the rumor author matches the seal + if (unsignedEvent.author() != sealEvent.author()) { + println("Author mismatch.") + return null + } + + // Cache the rumor for later use + setCachedRumor(event.id(), unsignedEvent) + + return unsignedEvent + } catch (e: Throwable) { + println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}") + return null + } + } + + private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? { + try { + val filter = Filter().identifier(giftId.toHex()) + val event = client?.database()?.query(filter)?.first() + + return event?.content()?.let { UnsignedEvent.fromJson(it) } + } catch (e: Throwable) { + throw IllegalStateException("Failed to get cached rumor: ${e.message}", e) + } + } + + private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { + try { + // Construct reference tags + val tags = listOf( + Tag.identifier(giftId.toHex()), + Tag.publicKey(rumor.author()), + Tag.custom("r", listOf(rumor.roomId().toString())), + Tag.custom("k", listOf("14")) + ) + + // Set event kind + val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) + + // Construct event + val event = EventBuilder(kind, rumor.asJson()) + .tags(tags) + .finalizeAsync(Keys.generate()) + + client?.database()?.saveEvent(event) + } catch (e: Throwable) { + println("Failed to set cached rumor: ${e.message}") + } + } + + suspend fun getChatRooms(): Set? { + try { + val userPubkey = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + + val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) + val kTag = SingleLetterTag.lowercase(Alphabet.K) + + // Get all DM events + val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm")) + val events = client?.database()?.query(filter) + + // Collect rooms + val roomsMap: MutableMap = mutableMapOf() + + events + ?.toVec() + ?.map { UnsignedEvent.fromJson(it.content()) } + ?.filter { it.tags().publicKeys().isNotEmpty() } + ?.forEach { event -> + val newRoom = Room.new(rumor = event, userPubkey = userPubkey) + val existingRoom = roomsMap[newRoom.id] + + // Check if the room already exists + if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) { + val rTag = SingleLetterTag.lowercase(Alphabet.R) + val filter = Filter().kind(kind).pubkey(userPubkey) + .customTag(rTag, newRoom.id.toString()) + + // Determine if it's an ongoing room + val isOngoing = + client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false + + // Append room to map + roomsMap[newRoom.id] = + if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom + } + } + + return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet() + } catch (e: Exception) { + println("Failed to get chat rooms: ${e.message}") + return null + } + } + + suspend fun getChatRoomMessages(roomId: Long): List { + try { + val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) + val filter = Filter().kind(kind).reference(roomId.toString()) + val events = client?.database()?.query(filter) + + // Merge the events + return events + ?.toVec() + ?.map { UnsignedEvent.fromJson(it.content()).ensureId() } + // Filter out events without public keys (receivers) + ?.filter { it.tags().publicKeys().isNotEmpty() } + ?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList() + } catch (e: Exception) { + throw IllegalStateException("Failed to get chat room messages: ${e.message}", e) + } + } + + suspend fun chatRoomConnect(members: List) { + try { + members.forEach { member -> + val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) + val filter = Filter().kind(kind).author(member).limit(1u) + + val stream = client?.streamEvents( + target = ReqTarget.auto(listOf(filter)), + id = null, + timeout = Duration.parse("3s"), + policy = ReqExitPolicy.ExitOnEose + ) + + stream?.next()?.let { res -> + val event = res.event ?: return@let + connectMsgRelays(event) + } + } + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch relays: ${e.message}", e) + } + } + + suspend fun connectMsgRelays(event: Event) { + try { + val urls = nip17ExtractRelayList(event) + for (url in urls) { + client?.addRelay(url, RelayCapabilities.gossip()) + client?.connectRelay(url) + } + } catch (e: Exception) { + throw IllegalStateException("Failed to connect to relays: ${e.message}", e) + } + } + + suspend fun sendMessage( + to: Set, + content: String, + subject: String? = null, + replies: List = emptyList(), + onRumorCreated: ((UnsignedEvent) -> Unit)? = null, + ) { + try { + val currentUser = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + + val tags = mutableListOf() + + // Add a subject tag if provided + if (subject != null) { + tags.add(Tag.custom("subject", listOf(subject))) + } + + // Add event tags for replies + if (replies.isNotEmpty()) { + replies.forEach { replyId -> + tags.add(Tag.event(replyId)) + } + } + + // Add public key tags for each recipient + to.forEach { pubkey -> + tags.add(Tag.publicKey(pubkey)) + } + + for (receiver in setOf(currentUser) + to) { + // Construct the rumor event + // NEVER SIGN this event with the current user signer + val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), content) + .tags(tags) + .finalizeUnsigned(currentUser) + .ensureId() + + // Emit the rumor to the chat screen + if (receiver == currentUser) { + onRumorCreated?.invoke(rumor) + } + + // Construct the gift wrap event + val gift = nip59MakeGiftWrapAsync( + signer = signer, + receiverPubkey = receiver, + rumor = rumor, + extraTags = listOf( + Tag.custom("k", listOf("14")) + ) + ) + + // Send the event to receiver's NIP-17 relays + val output = client?.sendEvent( + event = gift, + target = SendEventTarget.toNip17(), + ackPolicy = AckPolicy.none(), + authenticationTimeout = Duration.parse("2s") + ) + + if (output != null) { + // Keep track of sent events + sentEvents[output.id] = emptyList() + + // Keep track of rumor IDs + val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null") + rumorMap[id] = output.id + + // Collect failed outputs + output.failed.forEach { (relayUrl, reason) -> + println("Failed to send event to relay $relayUrl: $reason") + } + } + } + } catch (e: Exception) { + throw IllegalStateException("Failed to send message: ${e.message}", e) + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt new file mode 100644 index 0000000..705d287 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt @@ -0,0 +1,266 @@ +package su.reya.coop.nostr + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import rust.nostr.sdk.AsyncNostrSigner +import rust.nostr.sdk.Client +import rust.nostr.sdk.ClientBuilder +import rust.nostr.sdk.ClientNotification +import rust.nostr.sdk.Event +import rust.nostr.sdk.EventId +import rust.nostr.sdk.GossipConfig +import rust.nostr.sdk.Keys +import rust.nostr.sdk.KindStandard +import rust.nostr.sdk.LogLevel +import rust.nostr.sdk.Metadata +import rust.nostr.sdk.NostrDatabase +import rust.nostr.sdk.NostrGossip +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayMessageEnum +import rust.nostr.sdk.SignerAuthenticator +import rust.nostr.sdk.SleepWhenIdle +import rust.nostr.sdk.Timestamp +import rust.nostr.sdk.UnsignedEvent +import rust.nostr.sdk.initLogger +import su.reya.coop.UniversalSigner +import kotlin.time.Duration + +object NostrManager { + val instance = Nostr() + + val BOOTSTRAP_RELAYS = listOf( + "wss://relay.primal.net", + "wss://relay.ditto.pub", + "wss://user.kindpag.es", + ) + + val INDEXER_RELAY = listOf( + "wss://indexer.coracle.social", + ) + + val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY +} + +class Nostr { + var client: Client? = null + private set + var signer: UniversalSigner = UniversalSigner(Keys.generate()) + private set + + val messages = MessageManager(this) + val profiles = ProfileManager(this) + val relays = RelayManager(this) + + private val isInitialized = MutableStateFlow(false) + + private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) + val newEvents = _newEvents.asSharedFlow() + + suspend fun emitNewEvent(event: UnsignedEvent) { + _newEvents.emit(event) + } + + suspend fun init( + dbPath: String, + logLevel: LogLevel = LogLevel.WARN + ) { + try { + if (isInitialized.value) return + + // Initialize the logger for nostr client + initLogger(logLevel) + + // Initialize configurations for nostr client + val lmdb = NostrDatabase.lmdb(dbPath) + val gossip = NostrGossip.inMemory() + val authenticator = SignerAuthenticator(signer) + val idleTimeout = Duration.parse("5m") + + client = + ClientBuilder() + .authenticator(authenticator) + .database(lmdb) + .gossip(gossip) + .gossipConfig( + GossipConfig() + .noBackgroundRefresh() + .fetchTimeout(Duration.parse("2s")) + ) + .verifySubscriptions(false) + .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) + .build() + + isInitialized.value = true + } catch (e: Exception) { + throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e) + } + } + + suspend fun waitUntilInitialized() { + isInitialized.first { it } + } + + suspend fun connectBootstrapRelays() { + relays.connectBootstrapRelays() + } + + suspend fun reconnect() { + relays.reconnect() + } + + suspend fun disconnect() { + relays.disconnect() + } + + suspend fun prune() { + try { + client?.database()?.wipe() + } catch (e: Exception) { + throw IllegalStateException("Failed to prune database: ${e.message}", e) + } + } + + suspend fun setSigner(new: AsyncNostrSigner) { + try { + signer.switch(new) + } catch (e: Exception) { + throw IllegalStateException("Failed to set signer: ${e.message}", e) + } + } + + fun isSignedByUser(event: Event): Boolean { + return try { + signer.currentUser == event.author() + } catch (e: Exception) { + println("Failed to check if event is signed by user: ${e.message}") + false + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + suspend fun handleNotifications( + onMetadataUpdate: (PublicKey, Metadata) -> Unit, + onContactListUpdate: (List) -> Unit, + onNewMessage: (UnsignedEvent) -> Unit, + ) = supervisorScope { + val now = Timestamp.now() + val processedEvent = mutableSetOf() + val notifications = client?.notifications() ?: return@supervisorScope + + val giftWrapQueue = Channel(Channel.UNLIMITED) + var processedCount = 0 + var eoseReceived = false + + launch(Dispatchers.Default) { + for (event in giftWrapQueue) { + val rumor = messages.extractRumor(event) + processedCount++ + + // Trigger new message notification + if (rumor != null) { + if (rumor.createdAt().asSecs() >= now.asSecs()) { + onNewMessage(rumor) + } + } + + // Update sync state + messages.updateSyncState { + it.copy( + processedCount = processedCount, + isSyncing = !eoseReceived || !giftWrapQueue.isEmpty + ) + } + } + } + + while (true) { + val notification = notifications.next() ?: continue + + when (notification) { + is ClientNotification.Message -> { + val relayUrl = notification.relayUrl + + when (val message = notification.message.asEnum()) { + is RelayMessageEnum.EventMsg -> { + val event = message.event + + // Prevent processing duplicate events + if (processedEvent.contains(event.id())) continue + processedEvent.add(event.id()) + + when (event.kind().asStd()) { + KindStandard.METADATA -> { + try { + val metadata = Metadata.fromJson(event.content()) + onMetadataUpdate(event.author(), metadata) + } catch (e: Exception) { + println("Failed to parse metadata: $e") + } + } + + KindStandard.CONTACT_LIST -> { + if (isSignedByUser(event = event)) { + val pubkeys = event.tags().publicKeys() + // Get mutual contacts + profiles.getMutualContacts(pubkeys) + // Emit contact list update + onContactListUpdate(pubkeys) + } + } + + KindStandard.INBOX_RELAYS -> { + // Get all gift wrap events for the current user + if (isSignedByUser(event = event)) { + messages.getUserMessages(msgRelayList = event) + } + } + + KindStandard.GIFT_WRAP -> { + giftWrapQueue.send(event) + } + + else -> {} + } + } + + is RelayMessageEnum.EndOfStoredEvents -> { + if (message.subscriptionId == "gift-wraps") { + eoseReceived = true + if (giftWrapQueue.isEmpty) { + messages.updateSyncState { it.copy(isSyncing = false) } + } + } + } + + is RelayMessageEnum.Ok -> { + if (messages.sentEvents.containsKey(message.eventId)) { + val currentRelays = + messages.sentEvents[message.eventId] ?: emptyList() + messages.sentEvents[message.eventId] = currentRelays + relayUrl + } + } + + else -> { + /* Ignore other message types */ + } + } + } + + is ClientNotification.Shutdown -> { + break + } + + else -> { + /* Ignore other message types */ + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt new file mode 100644 index 0000000..a1cf522 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt @@ -0,0 +1,360 @@ +package su.reya.coop.nostr + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.statement.HttpResponse +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import rust.nostr.sdk.AckPolicy +import rust.nostr.sdk.Client +import rust.nostr.sdk.Contact +import rust.nostr.sdk.EventBuilder +import rust.nostr.sdk.Filter +import rust.nostr.sdk.Keys +import rust.nostr.sdk.Kind +import rust.nostr.sdk.KindStandard +import rust.nostr.sdk.Metadata +import rust.nostr.sdk.MetadataRecord +import rust.nostr.sdk.Nip05Address +import rust.nostr.sdk.Nip05Profile +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayCapabilities +import rust.nostr.sdk.RelayUrl +import rust.nostr.sdk.ReqExitPolicy +import rust.nostr.sdk.ReqTarget +import rust.nostr.sdk.SendEventTarget +import rust.nostr.sdk.SubscribeAutoCloseOptions +import rust.nostr.sdk.Timestamp +import su.reya.coop.UniversalSigner +import kotlin.time.Duration + +class ProfileManager(private val nostr: Nostr) { + private val client: Client? get() = nostr.client + private val signer: UniversalSigner get() = nostr.signer + + private val _metadataUpdates = + MutableSharedFlow>(extraBufferCapacity = 100) + val metadataUpdates = _metadataUpdates.asSharedFlow() + + private val _contactListUpdates = MutableSharedFlow>(extraBufferCapacity = 100) + val contactListUpdates = _contactListUpdates.asSharedFlow() + + suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) { + _metadataUpdates.emit(pubkey to metadata) + } + + suspend fun emitContactListUpdate(contacts: List) { + _contactListUpdates.emit(contacts) + } + + suspend fun getUserMetadata() { + try { + val author = signer.currentUser ?: throw IllegalStateException("User not signed in") + + // Get the latest metadata event + val metadataFilter = + Filter().kind(Kind.fromStd(KindStandard.METADATA)).author(author).limit(1u) + + // Get the latest contact list event + val contactFilter = + Filter().kind(Kind.fromStd(KindStandard.CONTACT_LIST)).author(author).limit(1u) + + // Get the latest messaging relay list event + val msgRelayFilter = + Filter().kind(Kind.fromStd(KindStandard.INBOX_RELAYS)).author(author).limit(1u) + + // Construct a target that includes all filters + val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter)) + val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) + + client?.subscribe(target = target, id = "user-metadata", closeOn = opts) + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e) + } + } + + suspend fun getMutualContacts(pubkeys: List) { + try { + val kind = Kind.fromStd(KindStandard.CONTACT_LIST) + val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong()) + val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) + + val target = mutableMapOf>() + NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> + target[RelayUrl.parse(relay)] = listOf(filter) + } + + client?.subscribe( + target = ReqTarget.manual(target), + id = "mutual-contacts", + closeOn = opts, + ) + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e) + } + } + + suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) { + // Send relay list event + val relayList = nostr.relays.getDefaultRelayList() + val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys) + + client?.sendEvent( + event = relayListEvent, + target = SendEventTarget.broadcast(), + ackPolicy = AckPolicy.all(), + okTimeout = Duration.parse("3s") + ) + + // Send messaging relay list event + val msgRelayList = nostr.relays.getDefaultMsgRelayList() + val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).finalizeAsync(keys) + + client?.sendEvent( + event = msgRelayListEvent, + target = SendEventTarget.toNip65(), + ackPolicy = AckPolicy.none() + ) + + // Send metadata event + val metadata = + Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture)) + val metadataEvent = EventBuilder.metadata(metadata).finalizeAsync(keys) + + client?.sendEvent( + event = metadataEvent, + target = SendEventTarget.broadcast(), + ackPolicy = AckPolicy.none() + ) + + // Send contact list event + val defaultContact = + Contact(PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x")) + val contactListEvent = EventBuilder.contactList(listOf(defaultContact)).finalizeAsync(keys) + + client?.sendEvent( + event = contactListEvent, + target = SendEventTarget.toNip65(), + ackPolicy = AckPolicy.none() + ) + + nostr.setSigner(keys) + } + + suspend fun updateProfile( + name: String? = null, + bio: String? = null, + picture: String? = null + ): Metadata { + val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in") + + try { + val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord() + val newRecord = record.copy( + displayName = name ?: record.displayName, + about = bio ?: record.about, + picture = picture ?: record.picture + ) + val newMetadata = Metadata.fromRecord(newRecord) + val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer) + + client?.sendEvent( + event = event, + target = SendEventTarget.broadcast(), + ackPolicy = AckPolicy.none() + ) + + return newMetadata + } catch (e: Exception) { + throw IllegalStateException("Failed to update identity: ${e.message}", e) + } + } + + private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? { + return try { + val kind = Kind.fromStd(KindStandard.METADATA) + val filter = Filter().kind(kind).author(pubkey).limit(1u) + val event = client?.database()?.query(filter)?.first() ?: return null + + Metadata.fromJson(event.content()) + } catch (e: Exception) { + println("Failed to get latest metadata: ${e.message}") + null + } + } + + suspend fun getAllCacheMetadata(): Map { + try { + val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(100u) + val events = client?.database()?.query(filter) + val results = mutableMapOf() + + events?.toVec()?.forEach { event -> + try { + val metadata = Metadata.fromJson(event.content()) + results[event.author()] = metadata + } catch (e: Exception) { + println("Failed to parse metadata: $e") + } + } + + return results + } catch (e: Exception) { + println("Failed to get all cache metadata: ${e.message}") + return emptyMap() + } + } + + suspend fun fetchMetadataBatch(keys: List) { + try { + val limit = keys.size.toULong() * 2u + val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) + + // Construct a filter for metadata events + val filter = Filter() + .kind(Kind.fromStd(KindStandard.METADATA)) + .authors(keys) + .limit(limit) + + // Construct request target + val target = mutableMapOf>() + NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> + target[RelayUrl.parse(relay)] = listOf(filter) + } + + client?.subscribe(target = ReqTarget.manual(target), closeOn = opts) + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e) + } + } + + suspend fun setContactList(contacts: List) { + try { + val contactList = contacts.map { Contact(it) } + val event = EventBuilder.contactList(contactList).finalizeAsync(signer) + + client?.sendEvent( + event = event, + target = SendEventTarget.broadcast(), + ackPolicy = AckPolicy.none(), + ) + } catch (e: Exception) { + throw IllegalStateException("Failed to set contact list: ${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): PublicKey { + try { + val address = Nip05Address.parse(query) + val profile = profileFromAddress(HttpClient(), address) + + return profile.publicKey() + } catch (e: Exception) { + throw IllegalStateException("Failed to search address: ${e.message}", e) + } + } + + suspend fun searchByNostr(query: String): List { + try { + // Add search relay + val searchRelay = RelayUrl.parse("wss://antiprimal.net") + if (client?.relay(searchRelay) == null) { + client?.addRelay(url = searchRelay, capabilities = RelayCapabilities.read()) + client?.connectRelay(searchRelay) + } + + val kinds = listOf(Kind.fromStd(KindStandard.METADATA)) + val filter = Filter().kinds(kinds).search(query).limit(10u) + val target = ReqTarget.manual(mapOf(searchRelay to listOf(filter))) + + val stream = client?.streamEvents( + target = target, + id = "search", + timeout = Duration.parse("3s"), + policy = ReqExitPolicy.ExitOnEose + ) + + // Collect the results + val results = mutableListOf() + + // Keep searching until the stream is closed or timeout + stream?.next()?.let { event -> + val event = event.event ?: return@let + results.add(event.author()) + } + + return results + } catch (e: Exception) { + throw IllegalStateException("Failed to search nostr: ${e.message}", e) + } + } + + suspend fun verifyActivity(pubkey: PublicKey): Timestamp? { + try { + val filter = Filter().author(pubkey).limit(3u) + val target = mutableMapOf>() + NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> + target[RelayUrl.parse(relay)] = listOf(filter) + } + + val events = client?.fetchEvents( + target = ReqTarget.manual(target), + timeout = Duration.parse("3s") + ) + + return events?.first()?.createdAt() + } catch (e: Exception) { + throw IllegalStateException("Failed to get latest activity: ${e.message}", e) + } + } + + suspend fun verifyContact(pubkey: PublicKey): Boolean { + try { + val currentUser = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + + val kind = Kind.fromStd(KindStandard.CONTACT_LIST) + val filter = Filter().kind(kind).author(currentUser).limit(1u) + + val events = client?.database()?.query(filter) + val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf() + + return pubkeys.contains(pubkey) + } catch (e: Exception) { + throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e) + } + } + + suspend fun mutualContacts(pubkey: PublicKey): Set { + try { + val currentUser = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + + val kind = Kind.fromStd(KindStandard.CONTACT_LIST) + val filter = Filter().kind(kind).pubkey(pubkey).limit(1u) + + val events = client?.database()?.query(filter) + val contacts = mutableSetOf() + + events?.toVec()?.filter { it.author() != currentUser }?.forEach { event -> + contacts.add(event.author()) + } + + return contacts.toSet() + } catch (e: Exception) { + throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt new file mode 100644 index 0000000..bc9330f --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt @@ -0,0 +1,183 @@ +package su.reya.coop.nostr + +import rust.nostr.sdk.AckPolicy +import rust.nostr.sdk.Client +import rust.nostr.sdk.EventBuilder +import rust.nostr.sdk.Filter +import rust.nostr.sdk.Kind +import rust.nostr.sdk.KindStandard +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayCapabilities +import rust.nostr.sdk.RelayMetadata +import rust.nostr.sdk.RelayStatus +import rust.nostr.sdk.RelayUrl +import rust.nostr.sdk.ReqExitPolicy +import rust.nostr.sdk.ReqTarget +import rust.nostr.sdk.SendEventTarget +import rust.nostr.sdk.SubscribeAutoCloseOptions +import rust.nostr.sdk.extractRelayList +import rust.nostr.sdk.nip17ExtractRelayList +import su.reya.coop.UniversalSigner +import kotlin.time.Duration + +class RelayManager(private val nostr: Nostr) { + private val client: Client? get() = nostr.client + private val signer: UniversalSigner get() = nostr.signer + + suspend fun connectBootstrapRelays() { + NostrManager.BOOTSTRAP_RELAYS.forEach { url -> + client?.addRelay(RelayUrl.parse(url)) + } + NostrManager.INDEXER_RELAY.forEach { url -> + client?.addRelay( + url = RelayUrl.parse(url), + capabilities = RelayCapabilities.gossip() + ) + } + // Connect to all bootstrap relays + client?.connect() + } + + suspend fun reconnect() { + NostrManager.ALL_RELAYS.forEach { url -> + try { + client?.relay(RelayUrl.parse(url)).let { relay -> + if (relay != null) { + if (relay.status() != RelayStatus.CONNECTED) { + relay.connect() + } + } + } + } catch (e: Exception) { + println("Failed to reconnect relay: ${e.message}") + } + } + } + + suspend fun disconnect() { + NostrManager.ALL_RELAYS.forEach { url -> + try { + client?.disconnectRelay(RelayUrl.parse(url)) + } catch (e: Exception) { + println("Failed to disconnect relay: ${e.message}") + } + } + } + + internal suspend fun getDefaultRelayList(): Map { + // Construct a list of relays + val relayList = mapOf( + RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ, + RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ, + RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE, + RelayUrl.parse("wss://nostr.superfriends.online") to RelayMetadata.WRITE + ) + + // Ensure all relays are added and connected + relayList.forEach { (relay, metadata) -> + client?.addRelay( + url = relay, + capabilities = + when (metadata) { + RelayMetadata.READ -> RelayCapabilities.read() + RelayMetadata.WRITE -> RelayCapabilities.write() + } + ) + client?.connectRelay(relay) + } + + return relayList + } + + internal suspend fun getDefaultMsgRelayList(): List { + // Construct a list of messaging relays + val msgRelayList = listOf( + RelayUrl.parse("wss://auth.nostr1.com"), + RelayUrl.parse("wss://nip17.com"), + ) + + // Ensure all relays are added and connected + msgRelayList.forEach { relay -> + client?.addRelay(relay, RelayCapabilities.none()) + client?.connectRelay(relay) + } + + return msgRelayList + } + + suspend fun setMsgRelays(urls: List) { + try { + val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer) + + client?.sendEvent( + event = event, + target = SendEventTarget.toNip65(), + ackPolicy = AckPolicy.none(), + ) + + val currentUser = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + + val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) + val filter = Filter().kind(kind).author(currentUser).limit(1u) + + val target = ReqTarget.auto(listOf(filter)) + val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) + + client?.subscribe(target = target, closeOn = opts) + } catch (e: Exception) { + throw IllegalStateException("Failed to set msg relays: ${e.message}", e) + } + } + + suspend fun getMsgRelays(publicKey: PublicKey): List { + try { + val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) + val filter = Filter().kind(kind).author(publicKey).limit(1u) + val events = client?.database()?.query(filter) + + return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList()) + } catch (e: Exception) { + throw IllegalStateException("Failed to get msg relays: ${e.message}", e) + } + } + + suspend fun fetchMsgRelays(publicKey: PublicKey): List { + try { + val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) + val filter = Filter().kind(kind).author(publicKey).limit(1u) + val target = ReqTarget.auto(listOf(filter)) + val events = client?.fetchEvents(target, timeout = Duration.parse("3s")) + + return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList()) + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e) + } + } + + suspend fun getRelayList(publicKey: PublicKey): Map { + try { + val kind = Kind.fromStd(KindStandard.RELAY_LIST) + val filter = Filter().kind(kind).author(publicKey).limit(1u) + val events = client?.database()?.query(filter) + + return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap()) + } catch (e: Exception) { + throw IllegalStateException("Failed to get relay list: ${e.message}", e) + } + } + + suspend fun setRelaylist(relays: Map) { + try { + val event = EventBuilder.relayList(relays).finalizeAsync(signer) + + client?.sendEvent( + event = event, + target = SendEventTarget.broadcast(), + ackPolicy = AckPolicy.none(), + ) + } catch (e: Exception) { + throw IllegalStateException("Failed to set msg relays: ${e.message}", e) + } + } +} \ No newline at end of file -- 2.49.1 From 9c2dc1e6a5b328c76468bbfb9994598ba5c5698c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 29 Jun 2026 13:16:08 +0700 Subject: [PATCH 2/3] move some files to nostr folder --- .../kotlin/su/reya/coop/AndroidExternalSigner.kt | 2 ++ .../kotlin/su/reya/coop/NostrForegroundService.kt | 1 + .../androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt | 8 ++++---- .../androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt | 6 +++--- .../kotlin/su/reya/coop/screens/RequestListScreen.kt | 2 +- .../androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt | 2 +- .../src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt | 5 +++++ .../su/reya/coop/{ => nostr}/ExternalSignerHandler.kt | 2 +- .../su/reya/coop/{ => nostr}/ExternalSignerProxy.kt | 2 +- .../src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt | 4 ---- shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt | 1 - .../kotlin/su/reya/coop/nostr/ProfileManager.kt | 1 - .../commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt | 1 - .../commonMain/kotlin/su/reya/coop/{ => nostr}/Room.kt | 2 +- .../commonMain/kotlin/su/reya/coop/{ => nostr}/Signer.kt | 2 +- 15 files changed, 21 insertions(+), 20 deletions(-) rename shared/src/commonMain/kotlin/su/reya/coop/{ => nostr}/ExternalSignerHandler.kt (98%) rename shared/src/commonMain/kotlin/su/reya/coop/{ => nostr}/ExternalSignerProxy.kt (98%) rename shared/src/commonMain/kotlin/su/reya/coop/{ => nostr}/Room.kt (99%) rename shared/src/commonMain/kotlin/su/reya/coop/{ => nostr}/Signer.kt (98%) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/AndroidExternalSigner.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/AndroidExternalSigner.kt index 3f367fe..136aa47 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/AndroidExternalSigner.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/AndroidExternalSigner.kt @@ -6,6 +6,8 @@ import android.content.Intent import androidx.core.net.toUri import rust.nostr.sdk.PublicKey import rust.nostr.sdk.UnsignedEvent +import su.reya.coop.nostr.ExternalSignerHandler +import su.reya.coop.nostr.ExternalSignerResult class AndroidExternalSigner( private val context: Context, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt index 76ebe5b..a604a0e 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import su.reya.coop.nostr.NostrManager +import su.reya.coop.nostr.roomId import java.io.File private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES" diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index 27d308e..6d42015 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -68,11 +68,11 @@ import rust.nostr.sdk.UnsignedEvent import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState -import su.reya.coop.Room import su.reya.coop.Screen -import su.reya.coop.formatAsGroupHeader -import su.reya.coop.humanReadable -import su.reya.coop.roomId +import su.reya.coop.nostr.Room +import su.reya.coop.nostr.formatAsGroupHeader +import su.reya.coop.nostr.humanReadable +import su.reya.coop.nostr.roomId import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.nameFlow 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 b18e45f..ce20113 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -95,10 +95,10 @@ import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState -import su.reya.coop.Room -import su.reya.coop.RoomKind import su.reya.coop.Screen -import su.reya.coop.ago +import su.reya.coop.nostr.Room +import su.reya.coop.nostr.RoomKind +import su.reya.coop.nostr.ago import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.nameFlow diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt index 3e2a67c..a902365 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt @@ -40,8 +40,8 @@ import kotlinx.coroutines.launch import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState -import su.reya.coop.RoomKind import su.reya.coop.Screen +import su.reya.coop.nostr.RoomKind @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt index bbb861a..8fdbff4 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt @@ -5,7 +5,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import su.reya.coop.NostrViewModel -import su.reya.coop.Room +import su.reya.coop.nostr.Room import su.reya.coop.short fun Room.nameFlow(viewModel: NostrViewModel): Flow { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 0452ce6..632f713 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -41,7 +41,12 @@ import rust.nostr.sdk.Tag import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent import su.reya.coop.blossom.BlossomClient +import su.reya.coop.nostr.ExternalSignerHandler +import su.reya.coop.nostr.ExternalSignerProxy import su.reya.coop.nostr.Nostr +import su.reya.coop.nostr.Room +import su.reya.coop.nostr.SignerPermissions +import su.reya.coop.nostr.roomId import su.reya.coop.storage.SecretStorage import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds diff --git a/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerHandler.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ExternalSignerHandler.kt similarity index 98% rename from shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerHandler.kt rename to shared/src/commonMain/kotlin/su/reya/coop/nostr/ExternalSignerHandler.kt index 920fe64..9f5ba98 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerHandler.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ExternalSignerHandler.kt @@ -1,4 +1,4 @@ -package su.reya.coop +package su.reya.coop.nostr import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json diff --git a/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerProxy.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ExternalSignerProxy.kt similarity index 98% rename from shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerProxy.kt rename to shared/src/commonMain/kotlin/su/reya/coop/nostr/ExternalSignerProxy.kt index deee922..53dce6d 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerProxy.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ExternalSignerProxy.kt @@ -1,4 +1,4 @@ -package su.reya.coop +package su.reya.coop.nostr import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.Event diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt index f4a089f..157f200 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt @@ -24,10 +24,6 @@ import rust.nostr.sdk.Tag import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.nip17ExtractRelayList import rust.nostr.sdk.nip59MakeGiftWrapAsync -import su.reya.coop.Room -import su.reya.coop.RoomKind -import su.reya.coop.UniversalSigner -import su.reya.coop.roomId import kotlin.time.Duration data class MessageSyncState( diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt index 705d287..b873b7a 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt @@ -29,7 +29,6 @@ import rust.nostr.sdk.SleepWhenIdle import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.initLogger -import su.reya.coop.UniversalSigner import kotlin.time.Duration object NostrManager { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt index a1cf522..a326965 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt @@ -26,7 +26,6 @@ import rust.nostr.sdk.ReqTarget import rust.nostr.sdk.SendEventTarget import rust.nostr.sdk.SubscribeAutoCloseOptions import rust.nostr.sdk.Timestamp -import su.reya.coop.UniversalSigner import kotlin.time.Duration class ProfileManager(private val nostr: Nostr) { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt index bc9330f..7c5569d 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt @@ -17,7 +17,6 @@ import rust.nostr.sdk.SendEventTarget import rust.nostr.sdk.SubscribeAutoCloseOptions import rust.nostr.sdk.extractRelayList import rust.nostr.sdk.nip17ExtractRelayList -import su.reya.coop.UniversalSigner import kotlin.time.Duration class RelayManager(private val nostr: Nostr) { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Room.kt similarity index 99% rename from shared/src/commonMain/kotlin/su/reya/coop/Room.kt rename to shared/src/commonMain/kotlin/su/reya/coop/nostr/Room.kt index 816e0a6..eed2744 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Room.kt @@ -1,4 +1,4 @@ -package su.reya.coop +package su.reya.coop.nostr import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.TimeZone diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Signer.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt similarity index 98% rename from shared/src/commonMain/kotlin/su/reya/coop/Signer.kt rename to shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt index dc98991..65fa513 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Signer.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt @@ -1,4 +1,4 @@ -package su.reya.coop +package su.reya.coop.nostr import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -- 2.49.1 From 109b298ed238396f6b4a70397c5fa7847ea817e3 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 29 Jun 2026 20:41:39 +0700 Subject: [PATCH 3/3] refactor viewmodel --- .../androidMain/kotlin/su/reya/coop/App.kt | 44 +- .../kotlin/su/reya/coop/MainActivity.kt | 68 +- .../kotlin/su/reya/coop/screens/ChatScreen.kt | 43 +- .../su/reya/coop/screens/ContactListScreen.kt | 16 +- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 58 +- .../su/reya/coop/screens/ImportScreen.kt | 16 +- .../kotlin/su/reya/coop/screens/MyQrScreen.kt | 6 +- .../su/reya/coop/screens/NewChatScreen.kt | 30 +- .../su/reya/coop/screens/NewIdentityScreen.kt | 12 +- .../su/reya/coop/screens/OnboardingScreen.kt | 8 +- .../su/reya/coop/screens/ProfileScreen.kt | 10 +- .../su/reya/coop/screens/RelayScreen.kt | 20 +- .../su/reya/coop/screens/RequestListScreen.kt | 8 +- .../reya/coop/screens/UpdateProfileScreen.kt | 17 +- .../kotlin/su/reya/coop/shared/RoomHelper.kt | 18 +- .../kotlin/su/reya/coop/Extensions.kt | 8 + .../kotlin/su/reya/coop/NostrViewModel.kt | 905 ------------------ .../kotlin/su/reya/coop/nostr/Signer.kt | 2 +- .../reya/coop/viewmodels/AccountViewModel.kt | 210 ++++ .../su/reya/coop/viewmodels/AppViewModel.kt | 64 ++ .../su/reya/coop/viewmodels/ChatViewModel.kt | 199 ++++ .../reya/coop/viewmodels/ProfileViewModel.kt | 248 +++++ .../su/reya/coop/viewmodels/RelayViewModel.kt | 149 +++ 23 files changed, 1126 insertions(+), 1033 deletions(-) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AccountViewModel.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AppViewModel.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ChatViewModel.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ProfileViewModel.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodels/RelayViewModel.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 47f75ba..1f06149 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -47,9 +47,30 @@ import su.reya.coop.screens.RelayScreen import su.reya.coop.screens.RequestListScreen import su.reya.coop.screens.ScanScreen import su.reya.coop.screens.UpdateProfileScreen +import su.reya.coop.viewmodels.AccountViewModel +import su.reya.coop.viewmodels.AppViewModel +import su.reya.coop.viewmodels.ChatViewModel +import su.reya.coop.viewmodels.ProfileViewModel +import su.reya.coop.viewmodels.RelayViewModel -val LocalNostrViewModel = staticCompositionLocalOf { - error("No NostrViewModel provided") +val LocalAppViewModel = staticCompositionLocalOf { + error("No AppViewModel provided") +} + +val LocalAccountViewModel = staticCompositionLocalOf { + error("No AccountViewModel provided") +} + +val LocalChatViewModel = staticCompositionLocalOf { + error("No ChatViewModel provided") +} + +val LocalProfileViewModel = staticCompositionLocalOf { + error("No ProfileViewModel provided") +} + +val LocalRelayViewModel = staticCompositionLocalOf { + error("No RelayViewModel provided") } val LocalSnackbarHostState = staticCompositionLocalOf { @@ -66,14 +87,21 @@ val LocalScanResult = staticCompositionLocalOf { @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable -fun App(viewModel: NostrViewModel) { +fun App( + appViewModel: AppViewModel, + accountViewModel: AccountViewModel, + chatViewModel: ChatViewModel, + profileViewModel: ProfileViewModel, + relayViewModel: RelayViewModel, +) { val context = LocalContext.current val activity = context as? ComponentActivity val backStack = rememberNavBackStack(Screen.Home) val navigator = remember(backStack) { Navigator(backStack) } val qrScanResult = remember { QrScanResult() } - val signerRequired by viewModel.signerRequired.collectAsStateWithLifecycle() + // Get the signer required state + val signerRequired by accountViewModel.signerRequired.collectAsStateWithLifecycle() // Snackbar val snackbarHostState = remember { SnackbarHostState() } @@ -100,7 +128,7 @@ fun App(viewModel: NostrViewModel) { } LaunchedEffect(Unit) { - viewModel.errorEvents.collect { message -> + appViewModel.errorEvents.collect { message -> snackbarHostState.showSnackbar(message) } } @@ -143,7 +171,11 @@ fun App(viewModel: NostrViewModel) { motionScheme = MotionScheme.expressive(), ) { CompositionLocalProvider( - LocalNostrViewModel provides viewModel, + LocalAppViewModel provides appViewModel, + LocalAccountViewModel provides accountViewModel, + LocalChatViewModel provides chatViewModel, + LocalProfileViewModel provides profileViewModel, + LocalRelayViewModel provides relayViewModel, LocalSnackbarHostState provides snackbarHostState, LocalNavigator provides navigator, LocalScanResult provides qrScanResult, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index 69ac4ad..fd4f8fa 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -10,11 +10,23 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch import su.reya.coop.coop.storage.SecretStore import su.reya.coop.nostr.NostrManager +import su.reya.coop.viewmodels.AccountViewModel +import su.reya.coop.viewmodels.AppViewModel +import su.reya.coop.viewmodels.ChatViewModel +import su.reya.coop.viewmodels.ProfileViewModel +import su.reya.coop.viewmodels.RelayViewModel import kotlin.system.exitProcess class MainActivity : ComponentActivity() { @@ -22,16 +34,37 @@ class MainActivity : ComponentActivity() { val externalSignerLauncher = ExternalSignerLauncher() } - private val viewModel: NostrViewModel by viewModels { + private val factory by lazy { object : ViewModelProvider.Factory { + private val secretStore = SecretStore(this@MainActivity) + private val androidSigner = + AndroidExternalSigner(this@MainActivity, externalSignerLauncher) + private val appVM = AppViewModel(NostrManager.instance, secretStore) + private val profileVM = ProfileViewModel(NostrManager.instance, appVM) + private val chatVM = ChatViewModel(NostrManager.instance, appVM) + private val accountVM = + AccountViewModel(NostrManager.instance, secretStore, appVM, androidSigner) + private val relayVM = RelayViewModel(NostrManager.instance, appVM, chatVM, profileVM) + override fun create(modelClass: Class): T { - val secretStore = SecretStore(this@MainActivity) - val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher) - return NostrViewModel(NostrManager.instance, secretStore, androidSigner) as T + return when { + modelClass.isAssignableFrom(AppViewModel::class.java) -> appVM + modelClass.isAssignableFrom(AccountViewModel::class.java) -> accountVM + modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatVM + modelClass.isAssignableFrom(ProfileViewModel::class.java) -> profileVM + modelClass.isAssignableFrom(RelayViewModel::class.java) -> relayVM + else -> throw IllegalArgumentException("Unknown ViewModel class") + } as T } } } + private val appViewModel: AppViewModel by viewModels { factory } + private val accountViewModel: AccountViewModel by viewModels { factory } + private val chatViewModel: ChatViewModel by viewModels { factory } + private val profileViewModel: ProfileViewModel by viewModels { factory } + private val relayViewModel: RelayViewModel by viewModels { factory } + override fun onCreate(savedInstanceState: Bundle?) { Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> throwable.printStackTrace() @@ -69,14 +102,33 @@ class MainActivity : ComponentActivity() { // Keep the splash screen visible until the signer check is complete splashScreen.setKeepOnScreenCondition { - viewModel.signerRequired.value == null + accountViewModel.signerRequired.value == null } - // Bind the lifecycle of the ViewModel to the Activity's lifecycle' - viewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle) + // Bind the lifecycle of the ViewModels + val lifecycle = ProcessLifecycleOwner.get().lifecycle + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + appViewModel.viewModelScope.launch { + owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + coroutineScope { + launch { chatViewModel.bindObservers() } + launch { profileViewModel.bindObservers() } + } + } + } + } + }) + relayViewModel.observeSignerAndCheckRelays() setContent { - App(viewModel = viewModel) + App( + appViewModel = appViewModel, + accountViewModel = accountViewModel, + chatViewModel = chatViewModel, + profileViewModel = profileViewModel, + relayViewModel = relayViewModel, + ) } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index 6d42015..793fd9d 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -65,8 +65,10 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent +import su.reya.coop.LocalAccountViewModel +import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.nostr.Room @@ -83,10 +85,12 @@ import su.reya.coop.short fun ChatScreen(id: Long, screening: Boolean = false) { val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current - val viewModel = LocalNostrViewModel.current + val accountViewModel = LocalAccountViewModel.current + val chatViewModel = LocalChatViewModel.current + val profileViewModel = LocalProfileViewModel.current // Get chat room by ID - val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() + val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } } // Show empty screen @@ -104,12 +108,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) { return } - val displayName by remember(room) { - room?.nameFlow(viewModel) ?: flowOf("Loading...") + val currentUser = accountViewModel.nostr.signer.currentUser + + val displayName by remember(room, currentUser) { + room?.nameFlow(profileViewModel, currentUser) ?: flowOf("Loading...") }.collectAsStateWithLifecycle("Loading...") val picture by remember(room) { - room?.pictureFlow(viewModel) ?: flowOf(null) + room?.pictureFlow(profileViewModel) ?: flowOf(null) }.collectAsStateWithLifecycle(null) var text by remember { mutableStateOf("") } @@ -129,7 +135,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { loading = true // Get messages - val initialMessages = viewModel.getChatRoomMessages(id) + val initialMessages = chatViewModel.getChatRoomMessages(id) messages.clear() messages.addAll(initialMessages) @@ -137,10 +143,10 @@ fun ChatScreen(id: Long, screening: Boolean = false) { loading = false // Get msg relays for each member - viewModel.chatRoomConnect(id) + chatViewModel.chatRoomConnect(id) // Handle new messages - viewModel.newEvents.collect { event -> + chatViewModel.newEvents.collect { event -> if (event.roomId() == id) { if (event.id() !in messages.map { it.id() }) { messages.add(0, event) @@ -314,7 +320,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { value = text, onValueChange = { text = it }, onSend = { - viewModel.sendMessage(id, text) + chatViewModel.sendMessage(id, text) text = "" } ) @@ -331,14 +337,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) { fun ScreenerCard(room: Room) { val pubkey = room.members.firstOrNull() ?: return - val viewModel = LocalNostrViewModel.current + val profileViewModel = LocalProfileViewModel.current val scope = rememberCoroutineScope() var isContact by remember { mutableStateOf(false) } var mutualContacts by remember { mutableStateOf>(emptySet()) } var lastActivity by remember { mutableStateOf(null) } - val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) } + val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } val metadata by metadataFlow.collectAsStateWithLifecycle() val profile = metadata?.asRecord() @@ -348,11 +354,11 @@ fun ScreenerCard(room: Room) { LaunchedEffect(pubkey) { scope.launch { // Check contact - viewModel.verifyContact(pubkey).let { isContact = it } + profileViewModel.verifyContact(pubkey).let { isContact = it } // Get mutual contacts - viewModel.mutualContacts(pubkey).let { mutualContacts = it } + profileViewModel.mutualContacts(pubkey).let { mutualContacts = it } // Get the last activity - viewModel.verifyActivity(pubkey)?.let { lastActivity = it } + profileViewModel.verifyActivity(pubkey)?.let { lastActivity = it } } } @@ -470,8 +476,9 @@ fun DateSeparator(date: String) { fun ChatMessage( rumor: UnsignedEvent ) { - val viewModel = LocalNostrViewModel.current - val currentUser = viewModel.currentUser() + val accountViewModel = LocalAccountViewModel.current + val chatViewModel = LocalChatViewModel.current + val currentUser = accountViewModel.nostr.signer.currentUser val isMine = rumor.author() == currentUser val bubbleShape = if (isMine) { @@ -505,7 +512,7 @@ fun ChatMessage( onClick = { val id = rumor.id() if (id != null) { - val sent = viewModel.isMessageSent(id) + val sent = chatViewModel.isMessageSent(id) println("Sent: $sent") } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt index 77bc870..188e25d 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt @@ -55,7 +55,7 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.Nip05Address import rust.nostr.sdk.PublicKey import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.shared.Avatar @@ -66,9 +66,9 @@ import su.reya.coop.short fun ContactListScreen() { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - val viewModel = LocalNostrViewModel.current + val profileViewModel = LocalProfileViewModel.current - val contactList by viewModel.contactList.collectAsStateWithLifecycle() + val contactList by profileViewModel.contactList.collectAsStateWithLifecycle() var openAddContactDialog by remember { mutableStateOf(false) } Scaffold( @@ -180,7 +180,7 @@ fun ContactListScreen() { @Composable fun AddContactDialog(onDismissRequest: () -> Unit) { val snackbarHostState = LocalSnackbarHostState.current - val viewModel = LocalNostrViewModel.current + val profileViewModel = LocalProfileViewModel.current val focusRequester = remember { FocusRequester() } var contact by remember { mutableStateOf("") } var isError by remember { mutableStateOf(false) } @@ -225,7 +225,7 @@ fun AddContactDialog(onDismissRequest: () -> Unit) { actions = { IconButton(onClick = { scope.launch { - val success = viewModel.addContact(contact) + val success = profileViewModel.addContact(contact) if (success) onDismissRequest() } }) { @@ -283,8 +283,8 @@ fun ContactListItem( total: Int = 0, onClick: () -> Unit, ) { - val viewModel = LocalNostrViewModel.current - val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) } + val profileViewModel = LocalProfileViewModel.current + val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } val metadata by metadataFlow.collectAsState(initial = null) val profile = metadata?.asRecord() @@ -293,7 +293,7 @@ fun ContactListItem( SegmentedListItem( onClick = onClick, - onLongClick = { viewModel.removeContact(pubkey) }, + onLongClick = { profileViewModel.removeContact(pubkey) }, shapes = ListItemDefaults.segmentedShapes( index = index, count = total 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 ce20113..02341e8 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -91,8 +91,12 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey +import su.reya.coop.LocalAccountViewModel +import su.reya.coop.LocalAppViewModel +import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileViewModel +import su.reya.coop.LocalRelayViewModel import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen @@ -113,7 +117,11 @@ fun HomeScreen() { val qrScanResult = LocalScanResult.current val snackbarHostState = LocalSnackbarHostState.current val clipboardManager = LocalClipboard.current - val viewModel = LocalNostrViewModel.current + val appViewModel = LocalAppViewModel.current + val accountViewModel = LocalAccountViewModel.current + val chatViewModel = LocalChatViewModel.current + val profileViewModel = LocalProfileViewModel.current + val relayViewModel = LocalRelayViewModel.current val scope = rememberCoroutineScope() @@ -121,15 +129,15 @@ fun HomeScreen() { val listState = rememberLazyListState() val pullToRefreshState = rememberPullToRefreshState() - val currentUser = viewModel.currentUser() ?: return - val currentUserProfile = viewModel.getMetadata(currentUser) + val currentUser = accountViewModel.nostr.signer.currentUser ?: return + val currentUserProfile = profileViewModel.getMetadata(currentUser) val userProfile by currentUserProfile.collectAsStateWithLifecycle() - val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() - val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle() - val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle() - val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() - val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState() + val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() + val isRelayListEmpty by relayViewModel.isRelayListEmpty.collectAsStateWithLifecycle() + val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle() + val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() + val isBannerDismissed by appViewModel.isNotificationBannerDismissed.collectAsState() val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } var showBottomSheet by remember { mutableStateOf(false) } @@ -157,7 +165,7 @@ fun HomeScreen() { } LaunchedEffect(Unit) { - viewModel.getChatRooms() + chatViewModel.getChatRooms() } LaunchedEffect(qrScanResult.content) { @@ -165,7 +173,7 @@ fun HomeScreen() { runCatching { PublicKey.parse(result) } .onSuccess { pubkey -> try { - val roomId = viewModel.createChatRoom(listOf(pubkey)) + val roomId = chatViewModel.createChatRoom(listOf(pubkey)) navigator.navigate(Screen.Chat(roomId)) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } @@ -281,7 +289,7 @@ fun HomeScreen() { horizontalArrangement = Arrangement.spacedBy(8.dp), ) { TextButton( - onClick = { viewModel.dismissNotificationBanner() }, + onClick = { appViewModel.dismissNotificationBanner() }, modifier = Modifier.weight(1f), ) { Text(text = "Maybe later") @@ -322,7 +330,7 @@ fun HomeScreen() { onRefresh = { scope.launch { isRefreshing = true - viewModel.refreshChatRooms() + chatViewModel.refreshChatRooms() isRefreshing = false } }, @@ -392,7 +400,7 @@ fun HomeScreen() { onDismissRequest = { showBottomSheet = false }, sheetState = sheetState, ) { - val pubkey = viewModel.currentUser() + val pubkey = accountViewModel.nostr.signer.currentUser val shortPubkey = pubkey?.short() ?: "Not available" val userName = @@ -479,7 +487,7 @@ fun HomeScreen() { // Show the relay setup dialog if the msg relay list is empty if (isRelayListEmpty) { ModalBottomSheet( - onDismissRequest = { viewModel.dismissRelayWarning() }, + onDismissRequest = { relayViewModel.dismissRelayWarning() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surfaceContainer, ) { @@ -586,7 +594,7 @@ fun HomeScreen() { scope.launch { isBusy = true try { - viewModel.refetchMsgRelays(currentUser) + relayViewModel.refetchMsgRelays(currentUser) } catch (e: Exception) { snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}") } @@ -606,7 +614,7 @@ fun HomeScreen() { enabled = !isBusy, onClick = { scope.launch { - viewModel.useDefaultMsgRelayList() + relayViewModel.useDefaultMsgRelayList() sheetState.hide() } }, @@ -629,18 +637,18 @@ fun HomeScreen() { @Composable fun NewRequests(requests: List) { val navigator = LocalNavigator.current - val viewModel = LocalNostrViewModel.current + val profileViewModel = LocalProfileViewModel.current val total = requests.size val firstRoom = requests.getOrNull(0) val secondRoom = requests.getOrNull(1) val firstName by remember(firstRoom) { - firstRoom?.nameFlow(viewModel) ?: flowOf("") + firstRoom?.nameFlow(profileViewModel) ?: flowOf("") }.collectAsStateWithLifecycle("Loading...") val secondName by remember(secondRoom) { - secondRoom?.nameFlow(viewModel) ?: flowOf("") + secondRoom?.nameFlow(profileViewModel) ?: flowOf("") }.collectAsStateWithLifecycle("") val supportingText = when { @@ -712,9 +720,9 @@ fun NewRequests(requests: List) { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ChatRoom(room: Room, onClick: () -> Unit) { - val viewModel = LocalNostrViewModel.current - val displayName by remember(room) { room.nameFlow(viewModel) }.collectAsStateWithLifecycle("Loading...") - val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsStateWithLifecycle(null) + val profileViewModel = LocalProfileViewModel.current + val displayName by remember(room) { room.nameFlow(profileViewModel) }.collectAsStateWithLifecycle("Loading...") + val picture by remember(room) { room.pictureFlow(profileViewModel) }.collectAsStateWithLifecycle(null) ListItem( modifier = Modifier.clickable(onClick = onClick), @@ -760,7 +768,7 @@ fun BottomMenuList( onDismiss: (suspend () -> Unit) -> Unit ) { val navigator = LocalNavigator.current - val viewModel = LocalNostrViewModel.current + val accountViewModel = LocalAccountViewModel.current val defaultMenuList = listOf( "Update Profile" to { navigator.navigate(Screen.UpdateProfile) }, @@ -790,7 +798,7 @@ fun BottomMenuList( } Spacer(modifier = Modifier.size(16.dp)) FilledTonalButton( - onClick = { onDismiss { viewModel.logout() } }, + onClick = { onDismiss { accountViewModel.logout() } }, colors = ButtonDefaults.filledTonalButtonColors( containerColor = MaterialTheme.colorScheme.error, contentColor = MaterialTheme.colorScheme.onError diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt index bb29514..7bfeaf5 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -58,8 +58,10 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.Keys import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.PublicKey +import su.reya.coop.LocalAccountViewModel +import su.reya.coop.LocalAppViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileViewModel import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen @@ -74,17 +76,19 @@ fun ImportScreen() { val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current val focusManager = LocalFocusManager.current - val viewModel = LocalNostrViewModel.current + val appViewModel = LocalAppViewModel.current + val accountViewModel = LocalAccountViewModel.current + val profileViewModel = LocalProfileViewModel.current val scope = rememberCoroutineScope() - val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false) + val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false) var secret by remember { mutableStateOf("") } var pubkey by remember { mutableStateOf(null) } // Get metadata when pubkey changes val metadata by remember(pubkey) { - pubkey?.let(viewModel::getMetadata) ?: flowOf(null) + pubkey?.let(profileViewModel::getMetadata) ?: flowOf(null) }.collectAsStateWithLifecycle(null) val profile = metadata?.asRecord() @@ -246,10 +250,10 @@ fun ImportScreen() { onClick = { scope.launch { if (pubkey == null) { - viewModel.verifyIdentity(secret).let { pubkey = it } + accountViewModel.verifyIdentity(secret).let { pubkey = it } } else { // Import the identity - viewModel.importIdentity(secret) + accountViewModel.importIdentity(secret) // Navigate to the home screen navigator.navigate(Screen.Home) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt index 1428253..b62ed30 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt @@ -19,16 +19,16 @@ import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import io.github.alexzhirkevich.qrose.rememberQrCodePainter import org.jetbrains.compose.resources.painterResource +import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState @Composable fun MyQrScreen() { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - val viewModel = LocalNostrViewModel.current - val currentUser = viewModel.currentUser() ?: return + val accountViewModel = LocalAccountViewModel.current + val currentUser = accountViewModel.nostr.signer.currentUser ?: return Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt index 59b1e16..f2ded0c 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_next @@ -54,8 +55,9 @@ import coop.composeapp.generated.resources.ic_scanner import kotlinx.coroutines.delay import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey +import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileViewModel import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen @@ -69,13 +71,15 @@ fun NewChatScreen() { val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current - val viewModel = LocalNostrViewModel.current + val chatViewModel = LocalChatViewModel.current + val profileViewModel = LocalProfileViewModel.current + + val contactList by profileViewModel.contactList.collectAsStateWithLifecycle() + var query by remember { mutableStateOf("") } - val contactList by viewModel.contactList.collectAsState(initial = emptySet()) val createGroup = remember { mutableStateOf(false) } val searchResults = remember { mutableStateListOf() } val selectedReceivers = remember { mutableStateListOf() } - var query by remember { mutableStateOf("") } LaunchedEffect(query) { if (query.length >= 3) { @@ -92,12 +96,12 @@ fun NewChatScreen() { selectedReceivers.add(pubkey) } } else if (query.contains("@")) { - val pubkey = viewModel.searchByAddress(query) + val pubkey = profileViewModel.searchByAddress(query) if (pubkey != null) { selectedReceivers.add(pubkey) } } else { - val results = viewModel.searchByNostr(query) + val results = profileViewModel.searchByNostr(query) searchResults.clear() searchResults.addAll(results) } @@ -167,7 +171,7 @@ fun NewChatScreen() { ) { ExtendedFloatingActionButton( onClick = { - val roomId = viewModel.createChatRoom(selectedReceivers.toList()) + val roomId = chatViewModel.createChatRoom(selectedReceivers.toList()) navigator.navigate(Screen.Chat(roomId)) }, expanded = false, @@ -258,7 +262,7 @@ fun NewChatScreen() { items = searchResults, selectedReceivers = selectedReceivers, onContactClick = { pubkey -> - val roomId = viewModel.createChatRoom(listOf(pubkey)) + val roomId = chatViewModel.createChatRoom(listOf(pubkey)) navigator.navigate(Screen.Chat(roomId)) }, ) @@ -269,7 +273,7 @@ fun NewChatScreen() { items = contactList.toList(), selectedReceivers = selectedReceivers, onContactClick = { pubkey -> - val roomId = viewModel.createChatRoom(listOf(pubkey)) + val roomId = chatViewModel.createChatRoom(listOf(pubkey)) navigator.navigate(Screen.Chat(roomId)) } ) @@ -286,8 +290,8 @@ fun ReceiverChip( pubkey: PublicKey, onRemove: () -> Unit ) { - val viewModel = LocalNostrViewModel.current - val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) } + val profileViewModel = LocalProfileViewModel.current + val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } val metadata by metadataFlow.collectAsState(initial = null) val profile = metadata?.asRecord() @@ -372,8 +376,8 @@ fun ContactListItem( onClick: () -> Unit, onLongClick: () -> Unit ) { - val viewModel = LocalNostrViewModel.current - val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) } + val profileViewModel = LocalProfileViewModel.current + val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } val metadata by metadataFlow.collectAsState(initial = null) val profile = metadata?.asRecord() diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt index 216e402..88eca83 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -5,17 +5,21 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch +import su.reya.coop.LocalAccountViewModel +import su.reya.coop.LocalAppViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileViewModel import su.reya.coop.Screen import su.reya.coop.shared.ProfileEditor @Composable fun NewIdentityScreen() { - val viewModel = LocalNostrViewModel.current + val appViewModel = LocalAppViewModel.current + val accountViewModel = LocalAccountViewModel.current + val profileViewModel = LocalProfileViewModel.current val navigator = LocalNavigator.current val scope = rememberCoroutineScope() - val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false) + val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false) ProfileEditor( title = "Create a new identity", @@ -24,7 +28,7 @@ fun NewIdentityScreen() { onBack = { navigator.goBack() }, onConfirm = { name, bio, bytes, type -> scope.launch { - viewModel.createIdentity(name, bio, bytes, type) + accountViewModel.createIdentity(name, bio, bytes, type, profileViewModel) navigator.navigate(Screen.Home) } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt index cb0ca6e..90a34c3 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -45,8 +45,8 @@ import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.coop import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource +import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.shared.getExpressiveFontFamily @@ -57,7 +57,7 @@ fun OnboardingScreen() { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current - val viewModel = LocalNostrViewModel.current + val accountViewModel = LocalAccountViewModel.current val scope = rememberCoroutineScope() val logoPainter = painterResource(Res.drawable.coop) @@ -158,9 +158,9 @@ fun OnboardingScreen() { FilledTonalButton( onClick = { scope.launch { - if (viewModel.isExternalSignerAvailable()) { + if (accountViewModel.isExternalSignerAvailable()) { try { - viewModel.connectExternalSigner() + accountViewModel.connectExternalSigner() navigator.navigate(Screen.Home) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt index 87ffd06..edb46af 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt @@ -44,8 +44,9 @@ import coop.composeapp.generated.resources.ic_share import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey +import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.shared.Avatar @@ -58,12 +59,13 @@ fun ProfileScreen(pubkey: String) { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current - val viewModel = LocalNostrViewModel.current + val chatViewModel = LocalChatViewModel.current + val profileViewModel = LocalProfileViewModel.current val scope = rememberCoroutineScope() val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return - val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) } + val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } val metadata by metadataFlow.collectAsStateWithLifecycle() val profile = metadata?.asRecord() @@ -159,7 +161,7 @@ fun ProfileScreen(pubkey: String) { onClick = { scope.launch { try { - val roomId = viewModel.createChatRoom(listOf(pubkey)) + val roomId = chatViewModel.createChatRoom(listOf(pubkey)) navigator.navigate(Screen.Chat(roomId)) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt index 8c23271..959f671 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt @@ -66,7 +66,7 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayUrl import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalRelayViewModel import su.reya.coop.LocalSnackbarHostState @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -74,7 +74,7 @@ import su.reya.coop.LocalSnackbarHostState fun RelayScreen() { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - val viewModel = LocalNostrViewModel.current + val relayViewModel = LocalRelayViewModel.current val scope = rememberCoroutineScope() val msgRelayList = remember { mutableStateListOf() } @@ -96,8 +96,8 @@ fun RelayScreen() { var relayToDelete by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - relayList.putAll(viewModel.currentUserRelayList()) - msgRelayList.addAll(viewModel.currentUserMsgRelayList()) + relayList.putAll(relayViewModel.currentUserRelayList()) + msgRelayList.addAll(relayViewModel.currentUserMsgRelayList()) } Scaffold( @@ -321,11 +321,11 @@ fun RelayScreen() { return@launch } try { - viewModel.removeMsgRelay(relayToDelete!!) + relayViewModel.removeMsgRelay(relayToDelete!!) msgRelayList.removeIf { it.toString() == relayToDelete } relayToDelete = null } catch (e: Exception) { - snackbarHostState.showSnackbar("Failed to remove relay") + snackbarHostState.showSnackbar("Failed to remove relay: ${e.message}") } } } @@ -349,7 +349,7 @@ fun AddRelayDialog( onMsgRelayAdded: (newRelay: String) -> Unit, onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit, ) { - val viewModel = LocalNostrViewModel.current + val relayViewModel = LocalRelayViewModel.current val snackbarHostState = LocalSnackbarHostState.current val scope = rememberCoroutineScope() @@ -401,17 +401,17 @@ fun AddRelayDialog( if (!isError) { when (selected) { "Messaging" -> { - viewModel.addMsgRelay(relayAddress) + relayViewModel.addMsgRelay(relayAddress) onMsgRelayAdded(relayAddress) } "Inbox" -> { - viewModel.addInboxRelay(relayAddress) + relayViewModel.addInboxRelay(relayAddress) onRelayAdded(relayAddress, RelayMetadata.WRITE) } "Outbox" -> { - viewModel.addOutboxRelay(relayAddress) + relayViewModel.addOutboxRelay(relayAddress) onRelayAdded(relayAddress, RelayMetadata.READ) } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt index a902365..53d6d73 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt @@ -37,8 +37,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import kotlinx.coroutines.launch +import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.nostr.RoomKind @@ -48,14 +48,14 @@ import su.reya.coop.nostr.RoomKind fun RequestListScreen() { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - val viewModel = LocalNostrViewModel.current + val chatViewModel = LocalChatViewModel.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() val pullToRefreshState = rememberPullToRefreshState() var isRefreshing by remember { mutableStateOf(false) } - val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() + val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() // Get all request rooms val requests = remember(chatRooms) { @@ -103,7 +103,7 @@ fun RequestListScreen() { onRefresh = { scope.launch { isRefreshing = true - viewModel.refreshChatRooms() + chatViewModel.refreshChatRooms() isRefreshing = false } }, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt index 276fb4c..35d4a0b 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt @@ -1,24 +1,27 @@ package su.reya.coop.screens import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch +import su.reya.coop.LocalAccountViewModel +import su.reya.coop.LocalAppViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileViewModel import su.reya.coop.shared.ProfileEditor @Composable fun UpdateProfileScreen() { - val viewModel = LocalNostrViewModel.current + val appViewModel = LocalAppViewModel.current + val accountViewModel = LocalAccountViewModel.current + val profileViewModel = LocalProfileViewModel.current val navigator = LocalNavigator.current val scope = rememberCoroutineScope() - val currentUser = viewModel.currentUser() ?: return - val metadata by viewModel.getMetadata(currentUser).collectAsState(initial = null) - val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false) + val currentUser = accountViewModel.nostr.signer.currentUser ?: return + val metadata by profileViewModel.getMetadata(currentUser).collectAsStateWithLifecycle() + val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false) val profile = metadata?.asRecord() @@ -32,7 +35,7 @@ fun UpdateProfileScreen() { onBack = { navigator.goBack() }, onConfirm = { name, bio, bytes, type -> scope.launch { - viewModel.updateProfile(name, bio, bytes, type) + profileViewModel.updateProfile(name, bio, bytes, type) navigator.goBack() } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt index 8fdbff4..7184393 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt @@ -4,18 +4,22 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import su.reya.coop.NostrViewModel +import rust.nostr.sdk.PublicKey import su.reya.coop.nostr.Room import su.reya.coop.short +import su.reya.coop.viewmodels.ProfileViewModel -fun Room.nameFlow(viewModel: NostrViewModel): Flow { +fun Room.nameFlow( + profileViewModel: ProfileViewModel, + currentUser: PublicKey? = null +): Flow { // Return early if there's a custom subject/room name subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) } val displayMembers = if (isGroup()) members.take(2) else members.take(1) if (displayMembers.isEmpty()) return flowOf("Unknown") - return combine(displayMembers.map { viewModel.getMetadata(it) }) { metadataArray -> + return combine(displayMembers.map { profileViewModel.getMetadata(it) }) { metadataArray -> val names = metadataArray.mapIndexed { i, metadata -> val profile = metadata?.asRecord() profile?.displayName?.takeIf { it.isNotBlank() } @@ -29,12 +33,12 @@ fun Room.nameFlow(viewModel: NostrViewModel): Flow { if (extraCount > 0) "$combined, +$extraCount" else combined } else { val name = names.first() - if (displayMembers.first() == viewModel.currentUser()) "$name (you)" else name + if (displayMembers.first() == currentUser) "$name (you)" else name } } } -fun Room.pictureFlow(viewModel: NostrViewModel): Flow { +fun Room.pictureFlow(profileViewModel: ProfileViewModel): Flow { val firstMember = members.firstOrNull() ?: return flowOf(null) - return viewModel.getMetadata(firstMember).map { it?.asRecord()?.picture } -} \ No newline at end of file + return profileViewModel.getMetadata(firstMember).map { it?.asRecord()?.picture } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt b/shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt new file mode 100644 index 0000000..3c5d624 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt @@ -0,0 +1,8 @@ +package su.reya.coop + +import rust.nostr.sdk.PublicKey + +fun PublicKey.short(): String { + val bech32 = toBech32() + return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4) +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt deleted file mode 100644 index 632f713..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ /dev/null @@ -1,905 +0,0 @@ -package su.reya.coop - -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.repeatOnLifecycle -import androidx.lifecycle.viewModelScope -import io.ktor.client.HttpClient -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.serialization.kotlinx.json.json -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import kotlinx.serialization.json.Json -import rust.nostr.sdk.AsyncNostrSigner -import rust.nostr.sdk.EventBuilder -import rust.nostr.sdk.EventId -import rust.nostr.sdk.Keys -import rust.nostr.sdk.Kind -import rust.nostr.sdk.KindStandard -import rust.nostr.sdk.Metadata -import rust.nostr.sdk.NostrConnect -import rust.nostr.sdk.NostrConnectUri -import rust.nostr.sdk.PublicKey -import rust.nostr.sdk.RelayMetadata -import rust.nostr.sdk.RelayUrl -import rust.nostr.sdk.Tag -import rust.nostr.sdk.Timestamp -import rust.nostr.sdk.UnsignedEvent -import su.reya.coop.blossom.BlossomClient -import su.reya.coop.nostr.ExternalSignerHandler -import su.reya.coop.nostr.ExternalSignerProxy -import su.reya.coop.nostr.Nostr -import su.reya.coop.nostr.Room -import su.reya.coop.nostr.SignerPermissions -import su.reya.coop.nostr.roomId -import su.reya.coop.storage.SecretStorage -import kotlin.time.Clock -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -class NostrViewModel( - private val nostr: Nostr, - private val secretStore: SecretStorage, - private val externalSignerHandler: ExternalSignerHandler? = null, -) : ViewModel() { - private val _isNotificationBannerDismissed = MutableStateFlow(false) - val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow() - - private val _signerRequired = MutableStateFlow(null) - val signerRequired = _signerRequired.asStateFlow() - - private val _isBusy = MutableStateFlow(false) - val isBusy = _isBusy.asStateFlow() - - private val _isPartialProcessedGiftWrap = MutableStateFlow(false) - val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow() - - private val _isRelayListEmpty = MutableStateFlow(false) - val isRelayListEmpty = _isRelayListEmpty.asStateFlow() - - private val _chatRooms = MutableStateFlow>(emptySet()) - val chatRooms = _chatRooms.asStateFlow() - - private val _contactList = MutableStateFlow>(emptySet()) - val contactList = _contactList.asStateFlow() - - private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) - val newEvents = _newEvents.asSharedFlow() - - private val _sentReports = MutableSharedFlow>>() - val sentReport = _sentReports.asSharedFlow() - - private val _errorEvents = Channel(Channel.BUFFERED) - val errorEvents = _errorEvents.receiveAsFlow() - - private val _metadataStore = mutableMapOf>() - - private val metadataRequestChannel = Channel(Channel.UNLIMITED) - - private val seenPublicKeys = mutableSetOf() - - val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - - init { - // Skip the splash screen if a user is already logged in - if (nostr.signer.currentUser != null) { - _signerRequired.value = false - } - - // Check if the notification banner has been dismissed - checkNotificationBannerDismissedStatus() - - // Check local stored secret (secret key or bunker) - login() - - // Automatically reconnect bootstrap relays - reconnect() - - // Observe the signer state and verify the relay list - observeSignerAndCheckRelays() - - // Get all local stored metadata - getCacheMetadata() - } - - fun bindLifecycle(lifecycle: Lifecycle) { - viewModelScope.launch { - lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - coroutineScope { - launch { runObserver() } - launch { runMetadataBatching() } - } - } - } - } - - override fun onCleared() { - super.onCleared() - - // Disconnect to all bootstrap relays - viewModelScope.launch { - withContext(NonCancellable) { - nostr.disconnect() - } - } - } - - private fun showError(message: String) { - viewModelScope.launch { - _errorEvents.send(message) - } - } - - private fun checkNotificationBannerDismissedStatus() { - viewModelScope.launch { - _isNotificationBannerDismissed.value = - secretStore.get("notification_banner_dismissed") == "true" - } - } - - private fun reconnect() { - viewModelScope.launch { - nostr.waitUntilInitialized() - nostr.reconnect() - } - } - - private suspend fun runObserver() = coroutineScope { - // Observe message sync progress - launch { - nostr.messages.messageSyncState.collect { state -> - // When at least some messages are processed, allow UI to show the list - if (state.processedCount > 0) { - _isPartialProcessedGiftWrap.value = true - } - - // Refresh UI every 10 messages OR when sync is fully done - if (state.processedCount % 10 == 0 || !state.isSyncing) { - refreshChatRooms() - } - } - } - - // Observe new messages - launch { - nostr.newEvents.collect { event -> - val roomId = event.roomId() - val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId } - - if (existingRoom == null) { - val currentUser = nostr.signer.currentUser - if (currentUser != null) { - val newRoom = Room.new(event, currentUser) - _chatRooms.update { (it + newRoom).sortedDescending().toSet() } - } - } else { - updateRoomList(roomId, event) - } - - _newEvents.emit(event) - } - } - - // Observe contact list updates - launch { - nostr.profiles.contactListUpdates.collect { contacts -> - _contactList.value = contacts.toSet() - } - } - - // Observe metadata updates - launch { - nostr.profiles.metadataUpdates.collect { (pubkey, metadata) -> - updateMetadata(pubkey, metadata) - } - } - } - - private suspend fun runMetadataBatching() = coroutineScope { - // Wait until the client is ready - nostr.waitUntilInitialized() - - val batch = mutableSetOf() - val timeout = 500L // 500ms timeout for batching - - while (true) { - // Get the first pubkey - val firstKey = metadataRequestChannel.receive() - batch.add(firstKey) - - // Get current time - val lastFlushTime = Clock.System.now().toEpochMilliseconds() - - while (batch.isNotEmpty()) { - // Get the next pubkey - val nextKey = withTimeoutOrNull(timeout.milliseconds) { - metadataRequestChannel.receive() - } - - // Only add the pubkey if it's not null - if (nextKey != null) batch.add(nextKey) - - // Get current time - val now = Clock.System.now().toEpochMilliseconds() - - // Check if the batch is full or timeout has passed - if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) { - val keysToRequest = batch.toList() - batch.clear() - - nostr.profiles.fetchMetadataBatch(keysToRequest) - } - } - } - } - - private fun getCacheMetadata() { - viewModelScope.launch { - // Wait until the client is ready - nostr.waitUntilInitialized() - - val results = nostr.profiles.getAllCacheMetadata() - results.forEach { (pubkey, metadata) -> - // Update the metadata state - updateMetadata(pubkey, metadata) - // Update seenPublicKeys to avoid duplicate requests - seenPublicKeys.add(pubkey) - } - } - } - - private fun login() { - viewModelScope.launch { - try { - val secret = withTimeoutOrNull(3.seconds) { - secretStore.get("user_signer") - } - - if (secret == null) { - _signerRequired.value = true - return@launch - } - - runCatching { - val signer = createSigner(secret) - nostr.setSigner(signer) - }.onSuccess { - _signerRequired.value = false - }.onFailure { e -> - showError("Login failed: ${e.message}") - _signerRequired.value = true - } - } catch (e: Exception) { - showError("Login failed: ${e.message}") - _signerRequired.value = true - } - } - } - - private fun observeSignerAndCheckRelays() { - viewModelScope.launch { - while (true) { - val pubkey = nostr.signer.currentUser - - if (pubkey != null) { - // Get chat rooms - val rooms = nostr.messages.getChatRooms() ?: emptySet() - if (rooms.isNotEmpty()) { - mergeChatRooms(rooms) - _isPartialProcessedGiftWrap.value = true - } - - // Get all metadata for the current user - nostr.profiles.getUserMetadata() - - // Small delay to ensure all relays are connected - delay(2.seconds) - - // Check if the relay list is empty - val relays = nostr.relays.getMsgRelays(pubkey) - if (relays.isEmpty()) _isRelayListEmpty.value = true - - break - } - - delay(500.milliseconds) - } - } - } - - private fun requestMetadata(pubkey: PublicKey) { - if (seenPublicKeys.add(pubkey)) { - viewModelScope.launch { - metadataRequestChannel.send(pubkey) - } - } - } - - private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) { - _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata - } - - fun getMetadata(pubkey: PublicKey): StateFlow { - val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) } - if (flow.value == null) { - requestMetadata(pubkey) - } - return flow.asStateFlow() - } - - fun currentUser(): PublicKey? { - return nostr.signer.currentUser - } - - fun logout() { - viewModelScope.launch { - try { - _isBusy.value = true - // Reset the nostr signer and prune the database - nostr.signer.switch(Keys.generate()) - nostr.prune() - } catch (e: Exception) { - showError("Logout encountered an error: ${e.message}") - } finally { - // Clear credentials from persistent storage - secretStore.clear("user_signer") - - // Reset all UI states - resetInternalState() - - _isBusy.value = false - _signerRequired.value = true - } - } - } - - private fun resetInternalState() { - _chatRooms.value = emptySet() - _contactList.value = emptySet() - _isPartialProcessedGiftWrap.value = false - _isRelayListEmpty.value = false - _isNotificationBannerDismissed.value = false - } - - fun dismissNotificationBanner() { - viewModelScope.launch { - secretStore.set("notification_banner_dismissed", "true") - _isNotificationBannerDismissed.value = true - } - } - - fun dismissRelayWarning() { - _isRelayListEmpty.value = false - } - - private suspend fun getOrInitAppKeys(): Keys { - val secret = secretStore.get("app_keys") - - // If app keys are already stored, use them - if (secret != null) { - return Keys.parse(secret) - } - - // Generate new app keys and save to the secret storage - val keys = Keys.generate() - secretStore.set("app_keys", keys.secretKey().toBech32()) - - return keys - } - - private suspend fun blossomUpload(file: ByteArray, contentType: String): String? { - try { - // Upload picture to Blossom - val blossom = BlossomClient( - url = "https://blossom.band", - client = HttpClient { - install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - prettyPrint = true - isLenient = true - }) - } - } - ) - - val descriptor = blossom.upload( - file = file, - contentType = contentType, - signer = nostr.signer.get() - ) - - return descriptor?.url - } catch (e: Exception) { - showError("Error: ${e.message}") - return null - } - } - - suspend fun updateProfile( - name: String? = null, - bio: String? = null, - picture: ByteArray? = null, - contentType: String? = null - ) { - _isBusy.value = true - try { - val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") } - val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl) - val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - // Update the metadata state after successfully published - updateMetadata(currentUser, newMetadata) - // Update local state - _isBusy.value = false - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - - suspend fun createIdentity( - name: String, - bio: String?, - picture: ByteArray?, - contentType: String? = null - ) { - _isBusy.value = true - - val keys = Keys.generate() - val secret = keys.secretKey().toBech32() - - try { - val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") } - // Create identity - nostr.profiles.createIdentity(keys = keys, name = name, bio, picture = avatarUrl) - // Persist the secret in the secret storage - secretStore.set("user_signer", secret) - // Update local states - _isBusy.value = false - _signerRequired.value = false - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - - private suspend fun createSigner(secret: String): AsyncNostrSigner { - return when { - secret.startsWith("nsec1") -> Keys.parse(secret) - - secret.startsWith("bunker://") -> { - val appKeys = getOrInitAppKeys() - val bunker = NostrConnectUri.parse(secret) - val timeout = 50.seconds // or Duration.parse("50s") - NostrConnect(uri = bunker, appKeys, timeout, null) - } - - secret.startsWith("nip55://") -> { - val handler = externalSignerHandler - ?: throw IllegalStateException("External signer not available on this platform") - - // Format: nip55://packageName/hexPubkey - val parts = secret.removePrefix("nip55://").split("/", limit = 2) - val packageName = parts[0] - val pubkey = PublicKey.parse(parts[1]) - - handler.setPackageName(packageName) - ExternalSignerProxy(handler, pubkey) - } - - else -> throw IllegalArgumentException("Invalid secret format") - } - } - - suspend fun verifyIdentity(secret: String): PublicKey? { - try { - val signer = createSigner(secret) - if (secret.startsWith("bunker://")) { - showError("Please approve the connection.") - } - return signer.getPublicKeyAsync() - } catch (e: Exception) { - showError("Error: ${e.message}") - return null - } - } - - suspend fun importIdentity(secret: String) { - _isBusy.value = true - try { - val signer = createSigner(secret) - // Update signer - nostr.setSigner(signer) - // Persist the secret in the secret storage - secretStore.set("user_signer", secret) - // Update local states - _signerRequired.value = false - _isBusy.value = false - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - - suspend fun connectExternalSigner() { - val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") - _isBusy.value = true - try { - val permissions = SignerPermissions.toJson( - listOf( - SignerPermissions.signEvent(0), - SignerPermissions.signEvent(3), - SignerPermissions.signEvent(10000), - SignerPermissions.signEvent(10050), - SignerPermissions.signEvent(10063), - SignerPermissions.signEvent(22242), - SignerPermissions.signEvent(30030), - SignerPermissions.signEvent(30315), - SignerPermissions.nip44Encrypt(), - SignerPermissions.nip44Decrypt(), - ) - ) - - val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected") - val signer = ExternalSignerProxy(handler, result.pubkey) - - // Update signer - nostr.setSigner(signer) - // Store the signer in the secret storage - secretStore.set("user_signer", "nip55://${result.packageName}/${result.pubkey.toHex()}") - // Update local states - _signerRequired.value = false - _isBusy.value = false - } catch (e: Exception) { - throw Exception("Notice: ${e.message}") - } - } - - fun isExternalSignerAvailable(): Boolean { - return externalSignerHandler?.isAvailable() == true - } - - suspend fun refetchMsgRelays(pubkey: PublicKey) { - val relays = nostr.relays.fetchMsgRelays(pubkey) - if (relays.isNotEmpty()) dismissRelayWarning() - } - - suspend fun useDefaultMsgRelayList() { - try { - val defaultRelays = nostr.relays.getDefaultMsgRelayList() - nostr.relays.setMsgRelays(defaultRelays) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - - suspend fun currentUserRelayList(): Map { - try { - val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - return nostr.relays.getRelayList(currentUser) - } catch (e: Exception) { - showError("Error: ${e.message}") - return emptyMap() - } - } - - suspend fun addInboxRelay(relay: String) { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserRelayList().toMutableMap() - relays[relayUrl] = RelayMetadata.WRITE - - nostr.relays.setRelaylist(relays) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - - suspend fun addOutboxRelay(relay: String) { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserRelayList().toMutableMap() - relays[relayUrl] = RelayMetadata.READ - - nostr.relays.setRelaylist(relays) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - - suspend fun removeRelay(relay: String) { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserRelayList().toMutableMap() - relays.remove(relayUrl) - - nostr.relays.setRelaylist(relays) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - - suspend fun currentUserMsgRelayList(): List { - try { - val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - return nostr.relays.getMsgRelays(currentUser) - } catch (e: Exception) { - showError("Error: ${e.message}") - return emptyList() - } - } - - suspend fun addMsgRelay(relay: String) { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserMsgRelayList().toMutableSet() - relays.add(relayUrl) - - nostr.relays.setMsgRelays(relays.toList()) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - - suspend fun removeMsgRelay(relay: String) { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserMsgRelayList().toMutableSet() - relays.remove(relayUrl) - - nostr.relays.setMsgRelays(relays.toList()) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - - private suspend fun newContact(publicKey: PublicKey) { - if (publicKey in contactList.value) return - - try { - val updated = contactList.value + publicKey - // Publish new event - nostr.profiles.setContactList(updated.toList()) - // Optimistic local update - _contactList.update { it + publicKey } - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - - suspend fun addContact(address: String): Boolean { - val pubkey = try { - if (address.contains("@")) { - nostr.profiles.searchByAddress(address) - } else { - PublicKey.parse(address) - } - } catch (e: Exception) { - showError("Invalid contact address: ${e.message}") - return false - } - - return run { - newContact(pubkey) - true - } - } - - fun removeContact(publicKey: PublicKey) { - viewModelScope.launch { - if (publicKey !in contactList.value) return@launch - - try { - val updated = contactList.value - publicKey - // Publish new event - nostr.profiles.setContactList(updated.toList()) - // Optimistic local update - _contactList.update { it - publicKey } - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - } - - fun createChatRoom(to: List): Long { - try { - if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in") - if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required") - - val currentUser = nostr.signer.currentUser ?: throw Exception("User not found") - - // Construct the rumor event - val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "") - .tags(to.map { Tag.publicKey(it) }) - .finalizeUnsigned(currentUser) - - // Check if the room already exists - val id = rumor.roomId() - val existingRoom = _chatRooms.value.firstOrNull { it.id == id } - - // If the room already exists, return its ID - if (existingRoom != null) { - return existingRoom.id - } - - // Create a room from the rumor event - val room = Room.new(rumor, currentUser) - - // Update the chat rooms state - _chatRooms.update { currentRooms -> - (currentRooms + room).sortedDescending().toSet() - } - - return room.id - } catch (e: Exception) { - throw IllegalArgumentException("Failed to create room: ${e.message}") - } - } - - fun getChatRoom(id: Long): Room? { - return chatRooms.value.firstOrNull { it.id == id } - } - - private fun mergeChatRooms(rooms: Set) { - _chatRooms.update { currentRooms -> - val merged = currentRooms.associateBy { it.id }.toMutableMap() - // Add or update rooms from the database - rooms.forEach { room -> - merged[room.id] = room - } - // Return as a sorted set to maintain UI consistency - merged.values.sortedDescending().toSet() - } - } - - fun getChatRooms() { - viewModelScope.launch { - val rooms = nostr.messages.getChatRooms() ?: emptySet() - mergeChatRooms(rooms) - } - } - - suspend fun refreshChatRooms() { - try { - val rooms = nostr.messages.getChatRooms() ?: emptySet() - mergeChatRooms(rooms) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - - suspend fun getChatRoomMessages(roomId: Long): List { - try { - return nostr.messages.getChatRoomMessages(roomId) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - - return emptyList() - } - - fun chatRoomConnect(roomId: Long) { - viewModelScope.launch { - try { - val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") - val members = room.members - - nostr.messages.chatRoomConnect(members.toList()) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - } - - fun sendMessage(roomId: Long, message: String, replies: List = emptyList()) { - if (message.isEmpty()) { - showError("Message cannot be empty") - } - viewModelScope.launch { - try { - val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") - nostr.messages.sendMessage( - to = room.members, - content = message, - subject = room.subject, - replies = replies, - onRumorCreated = { event -> - updateRoomList(roomId, event) - viewModelScope.launch { _newEvents.emit(event) } - }, - ) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - } - - fun isMessageSent(id: EventId): Boolean { - val giftWrapId = nostr.messages.rumorMap[id] - - if (giftWrapId != null) { - val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false - return isSent - } else { - return false - } - } - - private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) { - _chatRooms.update { currentRooms -> - currentRooms.map { room -> - if (room.id == roomId) { - room.copy( - lastMessage = newMessage.content(), - createdAt = newMessage.createdAt() - ) - } else { - room - } - }.sortedDescending().toSet() - } - } - - suspend fun searchByAddress(query: String): PublicKey? { - try { - return nostr.profiles.searchByAddress(query) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - return null - } - - suspend fun searchByNostr(query: String): List { - try { - return nostr.profiles.searchByNostr(query) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - return emptyList() - } - - suspend fun verifyActivity(pubkey: PublicKey): Timestamp? { - return try { - nostr.profiles.verifyActivity(pubkey) - } catch (e: Exception) { - showError("Error: ${e.message}") - null - } - } - - suspend fun verifyContact(pubkey: PublicKey): Boolean { - return try { - nostr.profiles.verifyContact(pubkey) - } catch (e: Exception) { - showError("Error: ${e.message}") - false - } - } - - suspend fun mutualContacts(pubkey: PublicKey): Set { - return try { - nostr.profiles.mutualContacts(pubkey) - } catch (e: Exception) { - showError("Error: ${e.message}") - setOf() - } - } -} - -fun PublicKey.short(): String { - val bech32 = toBech32() - return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4) -} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt index 65fa513..bfeef8a 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt @@ -63,4 +63,4 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner { override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String { return get().nip44DecryptAsync(publicKey, payload) } -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AccountViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AccountViewModel.kt new file mode 100644 index 0000000..e8b8d2e --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AccountViewModel.kt @@ -0,0 +1,210 @@ +package su.reya.coop.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import rust.nostr.sdk.AsyncNostrSigner +import rust.nostr.sdk.Keys +import rust.nostr.sdk.NostrConnect +import rust.nostr.sdk.NostrConnectUri +import rust.nostr.sdk.PublicKey +import su.reya.coop.nostr.ExternalSignerHandler +import su.reya.coop.nostr.ExternalSignerProxy +import su.reya.coop.nostr.Nostr +import su.reya.coop.nostr.SignerPermissions +import su.reya.coop.storage.SecretStorage +import kotlin.time.Duration.Companion.seconds + +class AccountViewModel( + val nostr: Nostr, + private val secretStore: SecretStorage, + private val appViewModel: AppViewModel, + private val externalSignerHandler: ExternalSignerHandler? = null, +) : ViewModel() { + private val _signerRequired = MutableStateFlow(null) + val signerRequired = _signerRequired.asStateFlow() + + init { + // Skip the splash screen if a user is already logged in + if (nostr.signer.currentUser != null) { + _signerRequired.value = false + } + // Check local stored secret + login() + } + + private fun login() { + viewModelScope.launch { + try { + val secret = withTimeoutOrNull(3.seconds) { + secretStore.get("user_signer") + } + + if (secret == null) { + _signerRequired.value = true + return@launch + } + + runCatching { + val signer = createSigner(secret) + nostr.setSigner(signer) + }.onSuccess { + _signerRequired.value = false + }.onFailure { e -> + appViewModel.showError("Login failed: ${e.message}") + _signerRequired.value = true + } + } catch (e: Exception) { + appViewModel.showError("Login failed: ${e.message}") + _signerRequired.value = true + } + } + } + + fun logout() { + viewModelScope.launch { + try { + appViewModel.setBusy(true) + // Reset the nostr signer and prune the database + nostr.signer.switch(Keys.generate()) + nostr.prune() + } catch (e: Exception) { + appViewModel.showError("Logout encountered an error: ${e.message}") + } finally { + // Clear credentials from persistent storage + secretStore.clear("user_signer") + secretStore.clear("notification_banner_dismissed") + // Reset local states + appViewModel.setBusy(false) + _signerRequired.value = true + } + } + } + + private suspend fun getOrInitAppKeys(): Keys { + val secret = secretStore.get("app_keys") + if (secret != null) return Keys.parse(secret) + + val keys = Keys.generate() + secretStore.set("app_keys", keys.secretKey().toBech32()) + + return keys + } + + private suspend fun createSigner(secret: String): AsyncNostrSigner { + return when { + secret.startsWith("nsec1") -> Keys.parse(secret) + secret.startsWith("bunker://") -> { + val appKeys = getOrInitAppKeys() + val bunker = NostrConnectUri.parse(secret) + val timeout = 50.seconds + NostrConnect(uri = bunker, appKeys, timeout, null) + } + + secret.startsWith("nip55://") -> { + val handler = externalSignerHandler + ?: throw IllegalStateException("External signer not available on this platform") + val parts = secret.removePrefix("nip55://").split("/", limit = 2) + val packageName = parts[0] + val pubkey = PublicKey.parse(parts[1]) + handler.setPackageName(packageName) + ExternalSignerProxy(handler, pubkey) + } + + else -> throw IllegalArgumentException("Invalid secret format") + } + } + + suspend fun verifyIdentity(secret: String): PublicKey? { + try { + val signer = createSigner(secret) + if (secret.startsWith("bunker://")) { + appViewModel.showError("Please approve the connection.") + } + return signer.getPublicKeyAsync() + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + return null + } + } + + suspend fun importIdentity(secret: String) { + appViewModel.setBusy(true) + try { + val signer = createSigner(secret) + nostr.setSigner(signer) + secretStore.set("user_signer", secret) + _signerRequired.value = false + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + } finally { + appViewModel.setBusy(false) + } + } + + suspend fun createIdentity( + name: String, + bio: String?, + picture: ByteArray?, + contentType: String? = "image/jpeg", + profileViewModel: ProfileViewModel + ) { + appViewModel.setBusy(true) + + val keys = Keys.generate() + val secret = keys.secretKey().toBech32() + + try { + val avatarUrl = picture?.let { profileViewModel.blossomUpload(it, contentType) } + nostr.profiles.createIdentity(keys = keys, name = name, bio, picture = avatarUrl) + // Set credentials in persistent storage + secretStore.set("user_signer", secret) + // Update local state + _signerRequired.value = false + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + } finally { + appViewModel.setBusy(false) + } + } + + suspend fun connectExternalSigner() { + val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") + appViewModel.setBusy(true) + try { + val permissions = SignerPermissions.toJson( + listOf( + SignerPermissions.signEvent(0), + SignerPermissions.signEvent(3), + SignerPermissions.signEvent(10000), + SignerPermissions.signEvent(10050), + SignerPermissions.signEvent(10063), + SignerPermissions.signEvent(22242), + SignerPermissions.signEvent(30030), + SignerPermissions.signEvent(30315), + SignerPermissions.nip44Encrypt(), + SignerPermissions.nip44Decrypt(), + ) + ) + + val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected") + val signer = ExternalSignerProxy(handler, result.pubkey) + nostr.setSigner(signer) + // Set credentials in persistent storage + secretStore.set("user_signer", "nip55://${result.packageName}/${result.pubkey.toHex()}") + // Update local state + _signerRequired.value = false + } catch (e: Exception) { + throw Exception("Notice: ${e.message}") + } finally { + appViewModel.setBusy(false) + } + } + + fun isExternalSignerAvailable(): Boolean { + return externalSignerHandler?.isAvailable() == true + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AppViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AppViewModel.kt new file mode 100644 index 0000000..ae383bf --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AppViewModel.kt @@ -0,0 +1,64 @@ +package su.reya.coop.viewmodels + +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.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import su.reya.coop.nostr.Nostr +import su.reya.coop.storage.SecretStorage + +class AppViewModel( + private val nostr: Nostr, + private val secretStore: SecretStorage +) : ViewModel() { + private val _isBusy = MutableStateFlow(false) + val isBusy = _isBusy.asStateFlow() + + private val _isNotificationBannerDismissed = MutableStateFlow(false) + val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow() + + private val _errorEvents = Channel(Channel.BUFFERED) + val errorEvents = _errorEvents.receiveAsFlow() + + init { + checkNotificationBannerDismissedStatus() + } + + fun setBusy(busy: Boolean) { + _isBusy.value = busy + } + + fun showError(message: String) { + viewModelScope.launch { + _errorEvents.send(message) + } + } + + private fun checkNotificationBannerDismissedStatus() { + viewModelScope.launch { + _isNotificationBannerDismissed.value = + secretStore.get("notification_banner_dismissed") == "true" + } + } + + fun dismissNotificationBanner() { + viewModelScope.launch { + secretStore.set("notification_banner_dismissed", "true") + _isNotificationBannerDismissed.value = true + } + } + + override fun onCleared() { + super.onCleared() + viewModelScope.launch { + withContext(NonCancellable) { + nostr.disconnect() + } + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ChatViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ChatViewModel.kt new file mode 100644 index 0000000..74dc5b9 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ChatViewModel.kt @@ -0,0 +1,199 @@ +package su.reya.coop.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import rust.nostr.sdk.EventBuilder +import rust.nostr.sdk.EventId +import rust.nostr.sdk.Kind +import rust.nostr.sdk.KindStandard +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.Tag +import rust.nostr.sdk.UnsignedEvent +import su.reya.coop.nostr.Nostr +import su.reya.coop.nostr.Room +import su.reya.coop.nostr.roomId + +class ChatViewModel( + private val nostr: Nostr, + private val appViewModel: AppViewModel +) : ViewModel() { + private val _chatRooms = MutableStateFlow>(emptySet()) + val chatRooms = _chatRooms.asStateFlow() + + private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) + val newEvents = _newEvents.asSharedFlow() + + private val _isPartialProcessedGiftWrap = MutableStateFlow(false) + val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow() + + val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + suspend fun bindObservers() = coroutineScope { + launch { + nostr.messages.messageSyncState.collect { state -> + if (state.processedCount > 0) { + _isPartialProcessedGiftWrap.value = true + } + if (state.processedCount % 10 == 0 || !state.isSyncing) { + refreshChatRooms() + } + } + } + + launch { + nostr.newEvents.collect { event -> + val roomId = event.roomId() + val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId } + + if (existingRoom == null) { + val currentUser = nostr.signer.currentUser + if (currentUser != null) { + val newRoom = Room.new(event, currentUser) + _chatRooms.update { (it + newRoom).sortedDescending().toSet() } + } + } else { + updateRoomList(roomId, event) + } + + _newEvents.emit(event) + } + } + } + + fun createChatRoom(to: List): Long { + try { + val currentUser = + nostr.signer.currentUser ?: throw IllegalStateException("User not signed in") + if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required") + + val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "") + .tags(to.map { Tag.publicKey(it) }) + .finalizeUnsigned(currentUser) + + val id = rumor.roomId() + val existingRoom = _chatRooms.value.firstOrNull { it.id == id } + + if (existingRoom != null) { + return existingRoom.id + } + + val room = Room.new(rumor, currentUser) + _chatRooms.update { currentRooms -> + (currentRooms + room).sortedDescending().toSet() + } + + return room.id + } catch (e: Exception) { + throw IllegalArgumentException("Failed to create room: ${e.message}") + } + } + + fun getChatRoom(id: Long): Room? { + return chatRooms.value.firstOrNull { it.id == id } + } + + fun getChatRooms() { + viewModelScope.launch { + val rooms = nostr.messages.getChatRooms() ?: emptySet() + mergeChatRooms(rooms) + } + } + + suspend fun refreshChatRooms() { + try { + val rooms = nostr.messages.getChatRooms() ?: emptySet() + mergeChatRooms(rooms) + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + } + } + + fun mergeChatRooms(rooms: Set) { + _chatRooms.update { currentRooms -> + val merged = currentRooms.associateBy { it.id }.toMutableMap() + rooms.forEach { room -> + merged[room.id] = room + } + merged.values.sortedDescending().toSet() + } + } + + suspend fun getChatRoomMessages(roomId: Long): List { + try { + return nostr.messages.getChatRoomMessages(roomId) + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + } + return emptyList() + } + + fun chatRoomConnect(roomId: Long) { + viewModelScope.launch { + try { + val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") + nostr.messages.chatRoomConnect(room.members.toList()) + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + } + } + } + + fun sendMessage(roomId: Long, message: String, replies: List = emptyList()) { + if (message.isEmpty()) { + appViewModel.showError("Message cannot be empty") + return + } + viewModelScope.launch { + try { + val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") + nostr.messages.sendMessage( + to = room.members, + content = message, + subject = room.subject, + replies = replies, + onRumorCreated = { event -> + updateRoomList(roomId, event) + viewModelScope.launch { _newEvents.emit(event) } + }, + ) + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + } + } + } + + fun isMessageSent(id: EventId): Boolean { + val giftWrapId = nostr.messages.rumorMap[id] + return if (giftWrapId != null) { + nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false + } else { + false + } + } + + private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) { + _chatRooms.update { currentRooms -> + currentRooms.map { room -> + if (room.id == roomId) { + room.copy( + lastMessage = newMessage.content(), + createdAt = newMessage.createdAt() + ) + } else { + room + } + }.sortedDescending().toSet() + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ProfileViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ProfileViewModel.kt new file mode 100644 index 0000000..b015e22 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ProfileViewModel.kt @@ -0,0 +1,248 @@ +package su.reya.coop.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.ktor.client.HttpClient +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import rust.nostr.sdk.Metadata +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.Timestamp +import su.reya.coop.blossom.BlossomClient +import su.reya.coop.nostr.Nostr +import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds + +class ProfileViewModel( + private val nostr: Nostr, + private val appViewModel: AppViewModel +) : ViewModel() { + private val _contactList = MutableStateFlow>(emptySet()) + val contactList = _contactList.asStateFlow() + + private val _metadataStore = mutableMapOf>() + private val metadataRequestChannel = Channel(Channel.UNLIMITED) + private val seenPublicKeys = mutableSetOf() + + init { + getCacheMetadata() + } + + suspend fun bindObservers() = coroutineScope { + launch { + nostr.profiles.contactListUpdates.collect { contacts -> + _contactList.value = contacts.toSet() + } + } + + launch { + nostr.profiles.metadataUpdates.collect { (pubkey, metadata) -> + updateMetadata(pubkey, metadata) + } + } + + launch { runMetadataBatching() } + } + + private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) { + _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata + } + + fun getMetadata(pubkey: PublicKey): StateFlow { + val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) } + if (flow.value == null) { + requestMetadata(pubkey) + } + return flow.asStateFlow() + } + + private fun requestMetadata(pubkey: PublicKey) { + if (seenPublicKeys.add(pubkey)) { + viewModelScope.launch { + metadataRequestChannel.send(pubkey) + } + } + } + + private fun getCacheMetadata() { + viewModelScope.launch { + nostr.waitUntilInitialized() + val results = nostr.profiles.getAllCacheMetadata() + results.forEach { (pubkey, metadata) -> + updateMetadata(pubkey, metadata) + seenPublicKeys.add(pubkey) + } + } + } + + private suspend fun runMetadataBatching() = coroutineScope { + nostr.waitUntilInitialized() + val batch = mutableSetOf() + val timeout = 500L + + while (true) { + val firstKey = metadataRequestChannel.receive() + batch.add(firstKey) + val lastFlushTime = Clock.System.now().toEpochMilliseconds() + + while (batch.isNotEmpty()) { + val nextKey = withTimeoutOrNull(timeout.milliseconds) { + metadataRequestChannel.receive() + } + if (nextKey != null) batch.add(nextKey) + val now = Clock.System.now().toEpochMilliseconds() + + if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) { + val keysToRequest = batch.toList() + batch.clear() + nostr.profiles.fetchMetadataBatch(keysToRequest) + } + } + } + } + + suspend fun blossomUpload(file: ByteArray, contentType: String?): String? { + try { + val blossom = BlossomClient( + url = "https://blossom.band", + client = HttpClient { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + prettyPrint = true + isLenient = true + }) + } + } + ) + + val descriptor = blossom.upload( + file = file, + contentType = contentType, + signer = nostr.signer.get() + ) + + return descriptor?.url + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + return null + } + } + + suspend fun updateProfile( + name: String? = null, + bio: String? = null, + picture: ByteArray? = null, + contentType: String? = null + ) { + appViewModel.setBusy(true) + try { + val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") } + val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl) + val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + updateMetadata(currentUser, newMetadata) + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + } finally { + appViewModel.setBusy(false) + } + } + + suspend fun addContact(address: String): Boolean { + val pubkey = try { + if (address.contains("@")) { + nostr.profiles.searchByAddress(address) + } else { + PublicKey.parse(address) + } + } catch (e: Exception) { + appViewModel.showError("Invalid contact address: ${e.message}") + return false + } + + if (pubkey in contactList.value) return true + + return try { + val updated = contactList.value + pubkey + nostr.profiles.setContactList(updated.toList()) + _contactList.update { it + pubkey } + true + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + false + } + } + + fun removeContact(publicKey: PublicKey) { + viewModelScope.launch { + if (publicKey !in contactList.value) return@launch + try { + val updated = contactList.value - publicKey + nostr.profiles.setContactList(updated.toList()) + _contactList.update { it - publicKey } + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + } + } + } + + suspend fun searchByAddress(query: String): PublicKey? { + try { + return nostr.profiles.searchByAddress(query) + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + } + return null + } + + suspend fun searchByNostr(query: String): List { + try { + return nostr.profiles.searchByNostr(query) + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + } + return emptyList() + } + + suspend fun verifyActivity(pubkey: PublicKey): Timestamp? { + return try { + nostr.profiles.verifyActivity(pubkey) + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + null + } + } + + suspend fun verifyContact(pubkey: PublicKey): Boolean { + return try { + nostr.profiles.verifyContact(pubkey) + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + false + } + } + + suspend fun mutualContacts(pubkey: PublicKey): Set { + return try { + nostr.profiles.mutualContacts(pubkey) + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + setOf() + } + } + + fun getUserMetadata() { + viewModelScope.launch { + nostr.profiles.getUserMetadata() + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/RelayViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/RelayViewModel.kt new file mode 100644 index 0000000..b779ef9 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/RelayViewModel.kt @@ -0,0 +1,149 @@ +package su.reya.coop.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayMetadata +import rust.nostr.sdk.RelayUrl +import su.reya.coop.nostr.Nostr +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class RelayViewModel( + private val nostr: Nostr, + private val appViewModel: AppViewModel, + private val chatViewModel: ChatViewModel, + private val profileViewModel: ProfileViewModel +) : ViewModel() { + private val _isRelayListEmpty = MutableStateFlow(false) + val isRelayListEmpty = _isRelayListEmpty.asStateFlow() + + fun reconnect() { + viewModelScope.launch { + nostr.waitUntilInitialized() + nostr.reconnect() + } + } + + fun observeSignerAndCheckRelays() { + viewModelScope.launch { + while (true) { + val pubkey = nostr.signer.currentUser + if (pubkey != null) { + val rooms = nostr.messages.getChatRooms() ?: emptySet() + if (rooms.isNotEmpty()) { + chatViewModel.mergeChatRooms(rooms) + // Note: isPartialProcessedGiftWrap is in ChatViewModel + // We might need to expose a way to set it or just let it be updated by observers + } + + profileViewModel.getUserMetadata() + + delay(2.seconds) + + val relays = nostr.relays.getMsgRelays(pubkey) + if (relays.isEmpty()) _isRelayListEmpty.value = true + break + } + delay(500.milliseconds) + } + } + } + + suspend fun refetchMsgRelays(pubkey: PublicKey) { + val relays = nostr.relays.fetchMsgRelays(pubkey) + if (relays.isNotEmpty()) dismissRelayWarning() + } + + fun dismissRelayWarning() { + _isRelayListEmpty.value = false + } + + suspend fun useDefaultMsgRelayList() { + try { + val defaultRelays = nostr.relays.getDefaultMsgRelayList() + nostr.relays.setMsgRelays(defaultRelays) + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + } + } + + suspend fun currentUserRelayList(): Map { + try { + val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + return nostr.relays.getRelayList(currentUser) + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + return emptyMap() + } + } + + suspend fun addInboxRelay(relay: String) { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserRelayList().toMutableMap() + relays[relayUrl] = RelayMetadata.WRITE + nostr.relays.setRelaylist(relays) + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + } + } + + suspend fun addOutboxRelay(relay: String) { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserRelayList().toMutableMap() + relays[relayUrl] = RelayMetadata.READ + nostr.relays.setRelaylist(relays) + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + } + } + + suspend fun removeRelay(relay: String) { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserRelayList().toMutableMap() + relays.remove(relayUrl) + nostr.relays.setRelaylist(relays) + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + } + } + + suspend fun currentUserMsgRelayList(): List { + try { + val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + return nostr.relays.getMsgRelays(currentUser) + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + return emptyList() + } + } + + suspend fun addMsgRelay(relay: String) { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserMsgRelayList().toMutableSet() + relays.add(relayUrl) + nostr.relays.setMsgRelays(relays.toList()) + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + } + } + + suspend fun removeMsgRelay(relay: String) { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserMsgRelayList().toMutableSet() + relays.remove(relayUrl) + nostr.relays.setMsgRelays(relays.toList()) + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + } + } +} -- 2.49.1