This commit is contained in:
2026-07-12 15:45:06 +07:00
parent 77ff35fbf7
commit 2d9bcab946
21 changed files with 1195 additions and 1017 deletions

View File

@@ -0,0 +1,605 @@
package su.reya.coop.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.EncryptedSecretKey
import rust.nostr.sdk.Keys
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.Timestamp
import su.reya.coop.AppStorage
import su.reya.coop.Profile
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.viewmodel.ErrorHost
import su.reya.coop.viewmodel.createErrorHost
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
data class AccountState(
val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false,
val isImporting: Boolean = false,
val importError: String? = null,
)
class AccountRepository(
private val nostr: Nostr,
private val storage: AppStorage,
private val mediaRepository: MediaRepository,
private val scope: CoroutineScope,
private val externalSignerHandler: ExternalSignerHandler? = null,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ErrorHost by createErrorHost() {
companion object {
private const val KEY_USER_SIGNER = "user_signer"
private const val KEY_APP_KEYS = "app_keys"
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
}
private val _state = MutableStateFlow(AccountState())
val state: StateFlow<AccountState> = _state.asStateFlow()
private val _isUpdatingProfile = MutableStateFlow(false)
val isUpdatingProfile: StateFlow<Boolean> = _isUpdatingProfile.asStateFlow()
@OptIn(ExperimentalCoroutinesApi::class)
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
.flatMapLatest { pubkey ->
if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null)
}
.stateIn(scope, SharingStarted.WhileSubscribed(5000), null)
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList: StateFlow<Set<PublicKey>> = _contactList.asStateFlow()
private val _isRelayListEmpty = MutableStateFlow(false)
val isRelayListEmpty: StateFlow<Boolean> = _isRelayListEmpty.asStateFlow()
private val _currentUserRelayList = MutableStateFlow<Map<RelayUrl, RelayMetadata?>>(emptyMap())
val currentUserRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> =
_currentUserRelayList.asStateFlow()
private val _currentUserMsgRelayList = MutableStateFlow<List<RelayUrl>>(emptyList())
val currentUserMsgRelayList: StateFlow<List<RelayUrl>> = _currentUserMsgRelayList.asStateFlow()
init {
checkNotificationBannerDismissedStatus()
login()
observeContactList()
}
private fun checkNotificationBannerDismissedStatus() {
scope.launch {
val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
}
}
private fun login() {
scope.launch {
try {
val secret = withTimeoutOrNull(5.seconds) {
storage.getSecret(KEY_USER_SIGNER)
}
if (secret == null) {
_state.update { it.copy(signerRequired = true) }
return@launch
}
runCatching {
val (signer, _) = createSigner(secret)
nostr.setSigner(signer)
}.onSuccess {
_state.update { it.copy(signerRequired = false) }
onSignerReady()
}.onFailure { e ->
showError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
} catch (e: Exception) {
showError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
}
}
fun logout(onLogout: () -> Unit = {}) {
scope.launch {
try {
nostr.signer.switch(Keys.generate())
nostr.prune()
} catch (e: Exception) {
showError("Logout encountered an error: ${e.message}")
} finally {
storage.clear(KEY_USER_SIGNER)
storage.clear(KEY_BANNER_DISMISSED)
onLogout()
_state.update { it.copy(signerRequired = true) }
}
}
}
fun dismissNotificationBanner() {
scope.launch {
storage.set(KEY_BANNER_DISMISSED, "true")
_state.update { it.copy(isNotificationBannerDismissed = true) }
}
}
private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) {
val secret = storage.getSecret(KEY_APP_KEYS)
if (secret != null) return@withContext Keys.parse(secret)
val keys = Keys.generate()
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
keys
}
private suspend fun createSigner(
secret: String,
password: String? = null
): Pair<AsyncNostrSigner, String?> = withContext(defaultDispatcher) {
when {
secret.startsWith("nsec1") -> Keys.parse(secret) to null
secret.startsWith("ncryptsec1") -> {
if (password == null) throw IllegalArgumentException("Password is required")
val enc = EncryptedSecretKey.fromBech32(secret)
val decrypted = enc.decrypt(password)
val keys = Keys(decrypted)
keys to keys.secretKey().toBech32()
}
secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = 50.seconds
NostrConnect(uri = bunker, appKeys, timeout, null) to null
}
secret.startsWith("nip55://") -> {
val handler = externalSignerHandler ?: throw IllegalStateException("Not available")
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
val packageName = parts[0]
val pubkey = PublicKey.parse(parts[1])
handler.setPackageName(packageName)
ExternalSignerProxy(handler, pubkey) to null
}
else -> throw IllegalArgumentException("Invalid secret format")
}
}
fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true
}
fun importIdentity(secret: String, password: String? = null) {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val (signer, decryptedSecret) = createSigner(secret, password)
nostr.setSigner(signer)
onSignerReady()
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("Import failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
fun connectExternalSigner() {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val handler =
externalSignerHandler ?: throw IllegalStateException("Signer not available")
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)
val uri = "nip55://${result.packageName}/${result.pubkey.toHex()}"
nostr.setSigner(signer)
onSignerReady()
storage.setSecret(KEY_USER_SIGNER, uri)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("External signer connection failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
}
nostr.profiles.createIdentity(
keys = keys,
name = name,
bio = bio,
picture = avatarUrl
)
onSignerReady()
storage.setSecret(KEY_USER_SIGNER, secret)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("Identity creation failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
private fun onSignerReady() {
getUserMetadata()
checkRelayList()
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun currentUserProfileFlow(pubkey: PublicKey) = merge(
flow {
nostr.waitUntilInitialized()
val cached = nostr.profiles.getAllCacheMetadata()[pubkey]
if (cached != null) emit(Profile(pubkey, cached))
nostr.profiles.fetchMetadataBatch(listOf(pubkey))
},
nostr.profiles.metadataUpdates
.filter { (p, _) -> p == pubkey }
.map { (p, m) -> Profile(p, m) }
)
fun getUserMetadata() {
scope.launch {
nostr.profiles.getUserMetadata()
}
}
fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) {
scope.launch {
_isUpdatingProfile.value = true
try {
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(
nostr.signer.get(),
it,
contentType ?: "image/jpeg"
)
}
nostr.profiles.updateProfile(name, bio, avatarUrl)
_isUpdatingProfile.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
_isUpdatingProfile.value = false
}
}
}
private fun observeContactList() {
scope.launch {
nostr.waitUntilInitialized()
nostr.profiles.contactListUpdates.collect { contacts ->
_contactList.value = contacts.toSet()
}
}
}
fun resetInternalState() {
_contactList.value = emptySet()
_isRelayListEmpty.value = false
}
fun addContact(address: String) {
scope.launch {
val pubkey = try {
if (address.contains("@")) {
nostr.profiles.searchByAddress(address)
} else {
PublicKey.parse(address)
}
} catch (e: Exception) {
showError("Invalid contact address: ${e.message}")
return@launch
}
if (pubkey in _contactList.value) return@launch
try {
val updated = _contactList.value + pubkey
nostr.profiles.setContactList(updated.toList())
_contactList.update { it + pubkey }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun removeContact(publicKey: PublicKey) {
scope.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) {
showError("Error: ${e.message}")
}
}
}
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.searchByAddress(query))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(null)
}
}
}
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.searchByNostr(query))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(emptyList())
}
}
}
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.verifyActivity(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(null)
}
}
}
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.verifyContact(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(false)
}
}
}
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.mutualContacts(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(emptySet())
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
fun checkRelayList() {
scope.launch {
val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first()
val relays = withTimeoutOrNull(10.seconds) {
var result = nostr.relays.getMsgRelays(currentUser)
while (result.isEmpty()) {
delay(500.milliseconds)
result = nostr.relays.getMsgRelays(currentUser)
}
result
} ?: emptyList()
if (relays.isEmpty()) _isRelayListEmpty.value = true
}
}
fun dismissRelayWarning() {
_isRelayListEmpty.value = false
}
fun refetchMsgRelays() {
scope.launch {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch
val relays = nostr.relays.fetchMsgRelays(currentUser)
if (relays.isNotEmpty()) dismissRelayWarning()
}
}
fun useDefaultMsgRelayList() {
scope.launch {
try {
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
nostr.relays.setMsgRelays(defaultRelays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun loadCurrentUserRelayList() {
scope.launch {
try {
val currentUser =
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_currentUserRelayList.value = nostr.relays.getRelayList(currentUser)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
private suspend fun currentUserRelayListInternal(): Map<RelayUrl, RelayMetadata?> {
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()
}
}
fun addInboxRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.WRITE
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun addOutboxRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.READ
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun removeRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays.remove(relayUrl)
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun loadCurrentUserMsgRelayList() {
scope.launch {
try {
val currentUser =
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_currentUserMsgRelayList.value = nostr.relays.getMsgRelays(currentUser)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
private suspend fun currentUserMsgRelayListInternal(): List<RelayUrl> {
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()
}
}
fun addMsgRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayListInternal().toMutableSet()
relays.add(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun removeMsgRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayListInternal().toMutableSet()
relays.remove(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
}

View File

@@ -0,0 +1,253 @@
package su.reya.coop.repository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
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.Room
import su.reya.coop.nostr.Nostr
import su.reya.coop.roomId
import su.reya.coop.viewmodel.ErrorHost
import su.reya.coop.viewmodel.createErrorHost
data class ChatState(
val rooms: Map<Long, Room> = emptyMap(),
val isPartialProcessedGiftWrap: Boolean = false,
)
class ChatRepository(
private val nostr: Nostr,
private val mediaRepository: MediaRepository,
private val scope: CoroutineScope,
) : ErrorHost by createErrorHost() {
private val _state = MutableStateFlow(ChatState())
val state = _state.stateIn(
scope,
SharingStarted.WhileSubscribed(5000),
ChatState()
)
private val _newEvents = MutableSharedFlow<UnsignedEvent>(
replay = 0,
extraBufferCapacity = 100,
onBufferOverflow = BufferOverflow.SUSPEND
)
val newEvents = _newEvents.asSharedFlow()
val chatRooms = state.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), emptyList())
val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
init {
scope.launch {
nostr.waitUntilInitialized()
// Observe message sync progress
launch {
nostr.messages.messageSyncState.collect { syncState ->
// When at least some messages are processed, allow UI to show the list
if (syncState.processedCount > 0 || !syncState.isSyncing) {
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
}
// Refresh UI every 100 messages OR when sync is fully done
if (syncState.processedCount % 100 == 0 || !syncState.isSyncing) {
refreshChatRooms()
}
}
}
// Observe new messages
launch {
nostr.newEvents.collect { event ->
val roomId = event.roomId()
val existingRoom = _state.value.rooms[roomId]
if (existingRoom == null) {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
val newRoom = Room.new(event, currentUser)
_state.update { it.copy(rooms = it.rooms + (newRoom.id to newRoom)) }
} else {
updateRoomList(roomId, event)
}
_newEvents.tryEmit(event)
}
}
}
}
fun createChatRoom(to: List<PublicKey>): Long {
try {
if (to.isEmpty()) {
throw IllegalArgumentException("At least one recipient is required")
}
// Get current user
val currentUser = nostr.signer.publicKeyFlow.value
?: throw IllegalStateException("User not signed in")
// 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 = _state.value.rooms[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
_state.update { it.copy(rooms = it.rooms + (room.id to room)) }
return room.id
} catch (e: Exception) {
throw IllegalArgumentException("Failed to create room: ${e.message}")
}
}
fun getChatRoom(id: Long): Room? {
return _state.value.rooms[id]
}
fun refreshChatRooms() {
scope.launch {
try {
val rooms = nostr.messages.getChatRooms() ?: emptySet()
_state.update { currentState ->
val newMap = currentState.rooms.toMutableMap()
rooms.forEach { room -> newMap[room.id] = room }
currentState.copy(rooms = newMap)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun loadChatRoomMessages(roomId: Long, onResult: (List<UnsignedEvent>) -> Unit) {
scope.launch {
try {
onResult(nostr.messages.getChatRoomMessages(roomId))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun chatRoomConnect(roomId: Long) {
scope.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<EventId> = emptyList()) {
if (message.isEmpty()) {
showError("Message cannot be empty")
return
}
scope.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)
scope.launch { _newEvents.tryEmit(event) }
},
)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun sendFileMessage(
roomId: Long,
file: ByteArray?,
contentType: String? = "image/jpeg",
replies: List<EventId> = emptyList()
) {
if (file == null) return
scope.launch {
try {
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
if (uri != null) sendMessage(roomId, uri, replies)
} catch (e: Exception) {
showError("File upload failed: ${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) {
_state.update { currentState ->
val room = currentState.rooms[roomId] ?: return@update currentState
val updatedRoom = room.copy(
lastMessage = newMessage.content(),
createdAt = newMessage.createdAt()
)
currentState.copy(rooms = currentState.rooms + (roomId to updatedRoom))
}
}
fun resetInternalState() {
_state.update {
it.copy(
rooms = emptyMap(),
isPartialProcessedGiftWrap = false,
)
}
}
}

View File

@@ -1,604 +1,56 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.EncryptedSecretKey
import rust.nostr.sdk.Keys
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.Timestamp
import su.reya.coop.AppStorage
import su.reya.coop.Profile
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.repository.MediaRepository
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
data class AccountState(
val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false,
val isImporting: Boolean = false,
val importError: String? = null,
)
import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.AccountState
class AccountViewModel(
private val nostr: Nostr,
private val storage: AppStorage,
private val mediaRepository: MediaRepository,
private val externalSignerHandler: ExternalSignerHandler? = null,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ViewModel(), ErrorHost by createErrorHost() {
companion object {
private const val KEY_USER_SIGNER = "user_signer"
private const val KEY_APP_KEYS = "app_keys"
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
}
private val _state = MutableStateFlow(AccountState())
val state: StateFlow<AccountState> = _state.asStateFlow()
private val _isUpdatingProfile = MutableStateFlow(false)
val isUpdatingProfile: StateFlow<Boolean> = _isUpdatingProfile.asStateFlow()
@OptIn(ExperimentalCoroutinesApi::class)
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
.flatMapLatest { pubkey ->
if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList: StateFlow<Set<PublicKey>> = _contactList.asStateFlow()
private val _isRelayListEmpty = MutableStateFlow(false)
val isRelayListEmpty: StateFlow<Boolean> = _isRelayListEmpty.asStateFlow()
private val _currentUserRelayList = MutableStateFlow<Map<RelayUrl, RelayMetadata?>>(emptyMap())
val currentUserRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> =
_currentUserRelayList.asStateFlow()
private val _currentUserMsgRelayList = MutableStateFlow<List<RelayUrl>>(emptyList())
val currentUserMsgRelayList: StateFlow<List<RelayUrl>> = _currentUserMsgRelayList.asStateFlow()
init {
checkNotificationBannerDismissedStatus()
login()
observeContactList()
}
private fun checkNotificationBannerDismissedStatus() {
viewModelScope.launch {
val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
}
}
private fun login() {
viewModelScope.launch {
try {
val secret = withTimeoutOrNull(5.seconds) {
storage.getSecret(KEY_USER_SIGNER)
}
if (secret == null) {
_state.update { it.copy(signerRequired = true) }
return@launch
}
runCatching {
val (signer, _) = createSigner(secret)
nostr.setSigner(signer)
}.onSuccess {
_state.update { it.copy(signerRequired = false) }
onSignerReady()
}.onFailure { e ->
showError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
} catch (e: Exception) {
showError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
}
}
fun logout(onLogout: () -> Unit = {}) {
viewModelScope.launch {
try {
nostr.signer.switch(Keys.generate())
nostr.prune()
} catch (e: Exception) {
showError("Logout encountered an error: ${e.message}")
} finally {
storage.clear(KEY_USER_SIGNER)
storage.clear(KEY_BANNER_DISMISSED)
onLogout()
_state.update { it.copy(signerRequired = true) }
}
}
}
fun dismissNotificationBanner() {
viewModelScope.launch {
storage.set(KEY_BANNER_DISMISSED, "true")
_state.update { it.copy(isNotificationBannerDismissed = true) }
}
}
private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) {
val secret = storage.getSecret(KEY_APP_KEYS)
if (secret != null) return@withContext Keys.parse(secret)
val keys = Keys.generate()
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
keys
}
private suspend fun createSigner(
secret: String,
password: String? = null
): Pair<AsyncNostrSigner, String?> = withContext(defaultDispatcher) {
when {
secret.startsWith("nsec1") -> Keys.parse(secret) to null
secret.startsWith("ncryptsec1") -> {
if (password == null) throw IllegalArgumentException("Password is required")
val enc = EncryptedSecretKey.fromBech32(secret)
val decrypted = enc.decrypt(password)
val keys = Keys(decrypted)
keys to keys.secretKey().toBech32()
}
secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = 50.seconds
NostrConnect(uri = bunker, appKeys, timeout, null) to null
}
secret.startsWith("nip55://") -> {
val handler = externalSignerHandler ?: throw IllegalStateException("Not available")
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
val packageName = parts[0]
val pubkey = PublicKey.parse(parts[1])
handler.setPackageName(packageName)
ExternalSignerProxy(handler, pubkey) to null
}
else -> throw IllegalArgumentException("Invalid secret format")
}
}
fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true
}
fun importIdentity(secret: String, password: String? = null) {
viewModelScope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val (signer, decryptedSecret) = createSigner(secret, password)
nostr.setSigner(signer)
onSignerReady()
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("Import failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
fun connectExternalSigner() {
viewModelScope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val handler =
externalSignerHandler ?: throw IllegalStateException("Signer not available")
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)
val uri = "nip55://${result.packageName}/${result.pubkey.toHex()}"
nostr.setSigner(signer)
onSignerReady()
storage.setSecret(KEY_USER_SIGNER, uri)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("External signer connection failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) {
viewModelScope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
}
nostr.profiles.createIdentity(
keys = keys,
name = name,
bio = bio,
picture = avatarUrl
)
onSignerReady()
storage.setSecret(KEY_USER_SIGNER, secret)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("Identity creation failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
private fun onSignerReady() {
getUserMetadata()
checkRelayList()
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun currentUserProfileFlow(pubkey: PublicKey) = merge(
flow {
nostr.waitUntilInitialized()
val cached = nostr.profiles.getAllCacheMetadata()[pubkey]
if (cached != null) emit(Profile(pubkey, cached))
nostr.profiles.fetchMetadataBatch(listOf(pubkey))
},
nostr.profiles.metadataUpdates
.filter { (p, _) -> p == pubkey }
.map { (p, m) -> Profile(p, m) }
)
fun getUserMetadata() {
viewModelScope.launch {
nostr.profiles.getUserMetadata()
}
}
fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) {
viewModelScope.launch {
_isUpdatingProfile.value = true
try {
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(
nostr.signer.get(),
it,
contentType ?: "image/jpeg"
)
}
nostr.profiles.updateProfile(name, bio, avatarUrl)
_isUpdatingProfile.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
_isUpdatingProfile.value = false
}
}
}
private fun observeContactList() {
viewModelScope.launch {
nostr.waitUntilInitialized()
nostr.profiles.contactListUpdates.collect { contacts ->
_contactList.value = contacts.toSet()
}
}
}
fun resetInternalState() {
_contactList.value = emptySet()
_isRelayListEmpty.value = false
}
fun addContact(address: String) {
viewModelScope.launch {
val pubkey = try {
if (address.contains("@")) {
nostr.profiles.searchByAddress(address)
} else {
PublicKey.parse(address)
}
} catch (e: Exception) {
showError("Invalid contact address: ${e.message}")
return@launch
}
if (pubkey in _contactList.value) return@launch
try {
val updated = _contactList.value + pubkey
nostr.profiles.setContactList(updated.toList())
_contactList.update { it + pubkey }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
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) {
showError("Error: ${e.message}")
}
}
}
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) {
viewModelScope.launch {
try {
onResult(nostr.profiles.searchByAddress(query))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(null)
}
}
}
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) {
viewModelScope.launch {
try {
onResult(nostr.profiles.searchByNostr(query))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(emptyList())
}
}
}
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) {
viewModelScope.launch {
try {
onResult(nostr.profiles.verifyActivity(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(null)
}
}
}
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) {
viewModelScope.launch {
try {
onResult(nostr.profiles.verifyContact(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(false)
}
}
}
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) {
viewModelScope.launch {
try {
onResult(nostr.profiles.mutualContacts(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(emptySet())
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
fun checkRelayList() {
viewModelScope.launch {
val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first()
val relays = withTimeoutOrNull(10.seconds) {
var result = nostr.relays.getMsgRelays(currentUser)
while (result.isEmpty()) {
delay(500.milliseconds)
result = nostr.relays.getMsgRelays(currentUser)
}
result
} ?: emptyList()
if (relays.isEmpty()) _isRelayListEmpty.value = true
}
}
fun dismissRelayWarning() {
_isRelayListEmpty.value = false
}
fun refetchMsgRelays() {
viewModelScope.launch {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch
val relays = nostr.relays.fetchMsgRelays(currentUser)
if (relays.isNotEmpty()) dismissRelayWarning()
}
}
fun useDefaultMsgRelayList() {
viewModelScope.launch {
try {
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
nostr.relays.setMsgRelays(defaultRelays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun loadCurrentUserRelayList() {
viewModelScope.launch {
try {
val currentUser =
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_currentUserRelayList.value = nostr.relays.getRelayList(currentUser)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
private suspend fun currentUserRelayListInternal(): Map<RelayUrl, RelayMetadata?> {
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()
}
}
fun addInboxRelay(relay: String) {
viewModelScope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.WRITE
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun addOutboxRelay(relay: String) {
viewModelScope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.READ
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun removeRelay(relay: String) {
viewModelScope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays.remove(relayUrl)
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun loadCurrentUserMsgRelayList() {
viewModelScope.launch {
try {
val currentUser =
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_currentUserMsgRelayList.value = nostr.relays.getMsgRelays(currentUser)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
private suspend fun currentUserMsgRelayListInternal(): List<RelayUrl> {
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()
}
}
fun addMsgRelay(relay: String) {
viewModelScope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayListInternal().toMutableSet()
relays.add(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun removeMsgRelay(relay: String) {
viewModelScope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayListInternal().toMutableSet()
relays.remove(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
private val repository: AccountRepository,
) : ViewModel(), ErrorHost by repository {
val state: StateFlow<AccountState> = repository.state
val isUpdatingProfile: StateFlow<Boolean> = repository.isUpdatingProfile
val currentUserProfile: StateFlow<Profile?> = repository.currentUserProfile
val contactList: StateFlow<Set<PublicKey>> = repository.contactList
val isRelayListEmpty: StateFlow<Boolean> = repository.isRelayListEmpty
val currentUserRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> = repository.currentUserRelayList
val currentUserMsgRelayList: StateFlow<List<RelayUrl>> = repository.currentUserMsgRelayList
fun logout(onLogout: () -> Unit = {}) = repository.logout(onLogout)
fun dismissNotificationBanner() = repository.dismissNotificationBanner()
fun isExternalSignerAvailable() = repository.isExternalSignerAvailable()
fun importIdentity(secret: String, password: String? = null) = repository.importIdentity(secret, password)
fun connectExternalSigner() = repository.connectExternalSigner()
fun createIdentity(name: String, bio: String?, picture: ByteArray?, contentType: String? = null) =
repository.createIdentity(name, bio, picture, contentType)
fun getUserMetadata() = repository.getUserMetadata()
fun updateProfile(name: String? = null, bio: String? = null, picture: ByteArray? = null, contentType: String? = null) =
repository.updateProfile(name, bio, picture, contentType)
fun resetInternalState() = repository.resetInternalState()
fun addContact(address: String) = repository.addContact(address)
fun removeContact(publicKey: PublicKey) = repository.removeContact(publicKey)
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) = repository.searchByAddress(query, onResult)
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) = repository.searchByNostr(query, onResult)
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) = repository.verifyActivity(pubkey, onResult)
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) = repository.verifyContact(pubkey, onResult)
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) = repository.mutualContacts(pubkey, onResult)
fun checkRelayList() = repository.checkRelayList()
fun dismissRelayWarning() = repository.dismissRelayWarning()
fun refetchMsgRelays() = repository.refetchMsgRelays()
fun useDefaultMsgRelayList() = repository.useDefaultMsgRelayList()
fun loadCurrentUserRelayList() = repository.loadCurrentUserRelayList()
fun addInboxRelay(relay: String) = repository.addInboxRelay(relay)
fun addOutboxRelay(relay: String) = repository.addOutboxRelay(relay)
fun removeRelay(relay: String) = repository.removeRelay(relay)
fun loadCurrentUserMsgRelayList() = repository.loadCurrentUserMsgRelayList()
fun addMsgRelay(relay: String) = repository.addMsgRelay(relay)
fun removeMsgRelay(relay: String) = repository.removeMsgRelay(relay)
}

View File

@@ -0,0 +1,78 @@
package su.reya.coop.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import rust.nostr.sdk.EventId
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.Profile
import su.reya.coop.Room
import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.ChatRepository
import su.reya.coop.roomId
class ChatScreenViewModel(
val id: Long,
val screening: Boolean,
private val accountRepository: AccountRepository,
private val chatRepository: ChatRepository,
) : ViewModel(), ErrorHost by chatRepository {
val currentUser: StateFlow<Profile?> = accountRepository.currentUserProfile
val chatRooms: StateFlow<List<Room>> = chatRepository.chatRooms
var loading by mutableStateOf(true)
var newOtherMessages by mutableIntStateOf(0)
var requireScreening by mutableStateOf(screening)
val messages = mutableStateListOf<UnsignedEvent>()
init {
loadMessages()
connect()
observeNewEvents()
}
private fun loadMessages() {
chatRepository.loadChatRoomMessages(id) { initialMessages ->
messages.clear()
messages.addAll(initialMessages)
loading = false
}
}
private fun connect() {
chatRepository.chatRoomConnect(id)
}
private fun observeNewEvents() {
viewModelScope.launch {
chatRepository.newEvents.collect { event ->
if (event.roomId() == id) {
if (event.id() !in messages.map { it.id() }) {
messages.add(0, event)
}
} else {
newOtherMessages++
}
}
}
}
fun sendMessage(text: String) {
chatRepository.sendMessage(id, text)
}
fun sendFileMessage(file: ByteArray?, type: String?) {
chatRepository.sendFileMessage(id, file, type)
}
fun isMessageSent(id: EventId): Boolean {
return chatRepository.isMessageSent(id)
}
}

View File

@@ -1,254 +1,41 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
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 kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
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.Room
import su.reya.coop.nostr.Nostr
import su.reya.coop.repository.MediaRepository
import su.reya.coop.roomId
import su.reya.coop.viewmodel.createErrorHost
import su.reya.coop.viewmodel.ErrorHost
data class ChatState(
val rooms: Map<Long, Room> = emptyMap(),
val isPartialProcessedGiftWrap: Boolean = false,
)
import su.reya.coop.repository.ChatRepository
import su.reya.coop.repository.ChatState
class ChatViewModel(
private val nostr: Nostr,
private val mediaRepository: MediaRepository,
) : ViewModel(), ErrorHost by createErrorHost() {
private val _state = MutableStateFlow(ChatState())
val state = _state.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
ChatState()
)
private val repository: ChatRepository,
) : ViewModel(), ErrorHost by repository {
val state: StateFlow<ChatState> = repository.state
val newEvents: SharedFlow<UnsignedEvent> = repository.newEvents
val chatRooms: StateFlow<List<Room>> = repository.chatRooms
val isSyncing: StateFlow<Boolean> = repository.isSyncing
val isPartialProcessedGiftWrap: StateFlow<Boolean> = repository.isPartialProcessedGiftWrap
private val _newEvents = MutableSharedFlow<UnsignedEvent>(
replay = 0,
extraBufferCapacity = 100,
onBufferOverflow = BufferOverflow.SUSPEND
)
val newEvents = _newEvents.asSharedFlow()
fun createChatRoom(to: List<PublicKey>): Long = repository.createChatRoom(to)
fun getChatRoom(id: Long): Room? = repository.getChatRoom(id)
fun refreshChatRooms() = repository.refreshChatRooms()
fun loadChatRoomMessages(roomId: Long, onResult: (List<UnsignedEvent>) -> Unit) =
repository.loadChatRoomMessages(roomId, onResult)
val chatRooms = state.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
init {
viewModelScope.launch {
nostr.waitUntilInitialized()
// Observe message sync progress
launch {
nostr.messages.messageSyncState.collect { syncState ->
// When at least some messages are processed, allow UI to show the list
if (syncState.processedCount > 0 || !syncState.isSyncing) {
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
}
// Refresh UI every 100 messages OR when sync is fully done
if (syncState.processedCount % 100 == 0 || !syncState.isSyncing) {
refreshChatRooms()
}
}
}
// Observe new messages
launch {
nostr.newEvents.collect { event ->
val roomId = event.roomId()
val existingRoom = _state.value.rooms[roomId]
if (existingRoom == null) {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
val newRoom = Room.new(event, currentUser)
_state.update { it.copy(rooms = it.rooms + (newRoom.id to newRoom)) }
} else {
updateRoomList(roomId, event)
}
_newEvents.tryEmit(event)
}
}
}
}
fun createChatRoom(to: List<PublicKey>): Long {
try {
if (to.isEmpty()) {
throw IllegalArgumentException("At least one recipient is required")
}
// Get current user
val currentUser = nostr.signer.publicKeyFlow.value
?: throw IllegalStateException("User not signed in")
// 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 = _state.value.rooms[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
_state.update { it.copy(rooms = it.rooms + (room.id to room)) }
return room.id
} catch (e: Exception) {
throw IllegalArgumentException("Failed to create room: ${e.message}")
}
}
fun getChatRoom(id: Long): Room? {
return _state.value.rooms[id]
}
fun refreshChatRooms() {
viewModelScope.launch {
try {
val rooms = nostr.messages.getChatRooms() ?: emptySet()
_state.update { currentState ->
val newMap = currentState.rooms.toMutableMap()
rooms.forEach { room -> newMap[room.id] = room }
currentState.copy(rooms = newMap)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun loadChatRoomMessages(roomId: Long, onResult: (List<UnsignedEvent>) -> Unit) {
viewModelScope.launch {
try {
onResult(nostr.messages.getChatRoomMessages(roomId))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
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<EventId> = emptyList()) {
if (message.isEmpty()) {
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.tryEmit(event) }
},
)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun chatRoomConnect(roomId: Long) = repository.chatRoomConnect(roomId)
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) =
repository.sendMessage(roomId, message, replies)
fun sendFileMessage(
roomId: Long,
file: ByteArray?,
contentType: String? = "image/jpeg",
replies: List<EventId> = emptyList()
) {
if (file == null) return
) = repository.sendFileMessage(roomId, file, contentType, replies)
viewModelScope.launch {
try {
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
if (uri != null) sendMessage(roomId, uri, replies)
} catch (e: Exception) {
showError("File upload failed: ${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) {
_state.update { currentState ->
val room = currentState.rooms[roomId] ?: return@update currentState
val updatedRoom = room.copy(
lastMessage = newMessage.content(),
createdAt = newMessage.createdAt()
)
currentState.copy(rooms = currentState.rooms + (roomId to updatedRoom))
}
}
fun resetInternalState() {
_state.update {
it.copy(
rooms = emptyMap(),
isPartialProcessedGiftWrap = false,
)
}
}
fun isMessageSent(id: EventId): Boolean = repository.isMessageSent(id)
fun resetInternalState() = repository.resetInternalState()
}

View File

@@ -96,10 +96,6 @@ class ProfileCache(
}
}
/**
* Returns a [StateFlow] for the profile of the given [pubkey].
* Triggers a metadata fetch if the profile is not yet cached.
*/
fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> {
val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) }
if (flow.value == null) requestMetadata(pubkey)