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.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow 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.UnsignedEvent import su.reya.coop.blossom.BlossomClient 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() 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 { refreshChatRooms() } 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 fun processIncomingEvent(event: UnsignedEvent) { val roomId = event.roomId() val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId } if (existingRoom == null) { nostr.signer.currentUser?.let { user -> val newRoom = Room.new(event, user) _chatRooms.update { (it + newRoom).sortedDescending().toSet() } } } else { updateRoomList(roomId, event) } } private suspend fun runObserver() = coroutineScope { // 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.contactListUpdates.collect { contacts -> _contactList.value = contacts.toSet() } } // Observe metadata updates launch { nostr.metadataUpdates.collect { (pubkey, metadata) -> updateMetadata(pubkey, metadata) } } // Observes subscription close launch { nostr.subscriptionClosed.collect { getChatRooms() _isPartialProcessedGiftWrap.value = true } } } 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) { val firstKey = metadataRequestChannel.receive() batch.add(firstKey) val lastFlushTime = Clock.System.now().toEpochMilliseconds() while (batch.isNotEmpty()) { val nextKey = withTimeoutOrNull(timeout.milliseconds) { metadataRequestChannel.receive() } // Only add the key 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.fetchMetadataBatch(keysToRequest) } } } } private fun getCacheMetadata() { viewModelScope.launch { // Wait until the client is ready nostr.waitUntilInitialized() val results = nostr.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.getChatRooms() ?: emptySet() if (rooms.isNotEmpty()) { mergeChatRooms(rooms) _isPartialProcessedGiftWrap.value = true } // Get all metadata for the current user nostr.getUserMetadata() // Small delay to ensure all relays are connected delay(3000.milliseconds) // Check if the relay list is empty val relays = nostr.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 { secretStore.clear("user_signer") nostr.signer.switch(Keys.generate()) _signerRequired.value = true } } 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.updateProfile(name, bio, avatarUrl) // Update the metadata state after successfully published updateMetadata(nostr.signer.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.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 useDefaultMsgRelayList() { try { val defaultRelays = nostr.getDefaultMsgRelayList() nostr.setMsgRelays(defaultRelays) } catch (e: Exception) { showError("Error: ${e.message}") } } suspend fun currentUserRelayList(): Map { try { return nostr.getRelayList(nostr.signer.currentUser!!) } catch (e: Exception) { showError("Error: ${e.message}") return emptyMap() } } suspend fun currentUserMsgRelayList(): List { try { return nostr.getMsgRelays(nostr.signer.currentUser!!) } catch (e: Exception) { showError("Error: ${e.message}") return emptyList() } } 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!! // 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.getChatRooms() ?: emptySet() mergeChatRooms(rooms) } } suspend fun refreshChatRooms() { try { val rooms = nostr.getChatRooms() ?: emptySet() mergeChatRooms(rooms) } catch (e: Exception) { showError("Error: ${e.message}") } } suspend fun getChatRoomMessages(roomId: Long): List { try { return nostr.getChatRoomMessages(roomId) } catch (e: Exception) { showError("Error: ${e.message}") } return emptyList() } suspend fun chatRoomConnect(roomId: Long): Map> { try { val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") val members = room.members return runCatching { nostr.chatRoomConnect(members.toList()) }.getOrElse { e -> showError("Error: ${e.message}") members.associateWith { emptyList() } } } catch (e: Exception) { showError("Error: ${e.message}") return emptyMap() } } 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.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.rumorMap[id] if (giftWrapId != null) { val isSent = nostr.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.searchByAddress(query) } catch (e: Exception) { showError("Error: ${e.message}") } return null } suspend fun searchByNostr(query: String): List { try { return nostr.searchByNostr(query) } catch (e: Exception) { showError("Error: ${e.message}") } return emptyList() } } fun PublicKey.short(): String { val bech32 = toBech32() return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4) }