From 785afbca084b75c19e1c6d14bce13baa8405f1b5 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 2 Jul 2026 16:09:53 +0700 Subject: [PATCH] . --- .../androidMain/kotlin/su/reya/coop/App.kt | 3 +- .../ErrorRepository.kt} | 4 +- .../reya/coop/repository/MediaRepository.kt | 39 ++++++++++++ .../su/reya/coop/viewmodel/AuthViewModel.kt | 8 ++- .../su/reya/coop/viewmodel/BaseViewModel.kt | 41 +----------- .../su/reya/coop/viewmodel/ChatViewModel.kt | 62 ++++++++++++------- .../su/reya/coop/viewmodel/NostrViewModel.kt | 24 +++---- 7 files changed, 99 insertions(+), 82 deletions(-) rename shared/src/commonMain/kotlin/su/reya/coop/{ErrorManager.kt => repository/ErrorRepository.kt} (82%) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/repository/MediaRepository.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index fb56b4e..8d242ce 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -34,6 +34,7 @@ import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay +import su.reya.coop.repository.ErrorRepository import su.reya.coop.screens.ChatScreen import su.reya.coop.screens.ContactListScreen import su.reya.coop.screens.HomeScreen @@ -117,7 +118,7 @@ fun App( } LaunchedEffect(Unit) { - ErrorManager.errors.collect { message -> + ErrorRepository.errors.collect { message -> snackbarHostState.showSnackbar(message) } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/ErrorManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/ErrorRepository.kt similarity index 82% rename from shared/src/commonMain/kotlin/su/reya/coop/ErrorManager.kt rename to shared/src/commonMain/kotlin/su/reya/coop/repository/ErrorRepository.kt index b4fc51e..8116902 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/ErrorManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/ErrorRepository.kt @@ -1,9 +1,9 @@ -package su.reya.coop +package su.reya.coop.repository import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow -object ErrorManager { +object ErrorRepository { private val _errors = Channel(Channel.BUFFERED) val errors = _errors.receiveAsFlow() diff --git a/shared/src/commonMain/kotlin/su/reya/coop/repository/MediaRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/MediaRepository.kt new file mode 100644 index 0000000..05fe570 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/MediaRepository.kt @@ -0,0 +1,39 @@ +package su.reya.coop.repository + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import rust.nostr.sdk.AsyncNostrSigner +import su.reya.coop.blossom.BlossomClient + +class MediaRepository { + private val httpClient = HttpClient { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + prettyPrint = true + isLenient = true + }) + } + } + + suspend fun blossomUpload( + signer: AsyncNostrSigner, + file: ByteArray, + contentType: String? = "image/jpeg" + ): String? { + return try { + val blossom = BlossomClient(url = "https://blossom.band", client = httpClient) + val descriptor = blossom.upload( + file = file, + contentType = contentType, + signer = signer, + ) + descriptor?.url + } catch (e: Exception) { + println("Upload failed: ${e.message}") + null + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt index 56b924d..5d8b226 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt @@ -15,6 +15,7 @@ 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 su.reya.coop.storage.SecretStorage import kotlin.time.Duration.Companion.seconds @@ -29,6 +30,8 @@ class AuthViewModel( private val secretStore: SecretStorage, private val externalSignerHandler: ExternalSignerHandler? = null, ) : BaseViewModel() { + private val mediaRepository = MediaRepository() + companion object { private const val KEY_USER_SIGNER = "user_signer" private const val KEY_APP_KEYS = "app_keys" @@ -236,8 +239,9 @@ class AuthViewModel( val secret = keys.secretKey().toBech32() try { - val avatarUrl = - picture?.let { blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg") } + val avatarUrl = picture?.let { + mediaRepository.blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg") + } // Create identity nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt index d09c30a..e1b0c13 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt @@ -1,47 +1,10 @@ package su.reya.coop.viewmodel import androidx.lifecycle.ViewModel -import io.ktor.client.HttpClient -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.serialization.kotlinx.json.json -import kotlinx.serialization.json.Json -import rust.nostr.sdk.AsyncNostrSigner -import su.reya.coop.ErrorManager -import su.reya.coop.blossom.BlossomClient +import su.reya.coop.repository.ErrorRepository abstract class BaseViewModel : ViewModel() { - protected val httpClient by lazy { - HttpClient { - install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - prettyPrint = true - isLenient = true - }) - } - } - } - protected fun showError(message: String) { - ErrorManager.showError(message) - } - - protected suspend fun blossomUpload( - signer: AsyncNostrSigner, - file: ByteArray, - contentType: String? = "image/jpeg" - ): String? { - return try { - val blossom = BlossomClient(url = "https://blossom.band", client = httpClient) - val descriptor = blossom.upload( - file = file, - contentType = contentType, - signer = signer, - ) - descriptor?.url - } catch (e: Exception) { - showError("Upload failed: ${e.message}") - null - } + ErrorRepository.showError(message) } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt index caf6e23..e848f5f 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt @@ -5,7 +5,7 @@ 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.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -20,19 +20,27 @@ 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 data class ChatState( + val rooms: Set = emptySet(), val isSyncing: Boolean = false, val isPartialProcessedGiftWrap: Boolean = false, ) class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { - private val _state = MutableStateFlow(ChatState()) - val state = _state.asStateFlow() + private val mediaRepository = MediaRepository() - private val _chatRooms = MutableStateFlow>(emptySet()) - val chatRooms = _chatRooms.asStateFlow() + private val _state = MutableStateFlow(ChatState()) + val state = combine( + _state, + nostr.messages.messageSyncState + ) { local, state -> local.copy(isSyncing = state.isSyncing) }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + ChatState() + ) private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) val newEvents = _newEvents.asSharedFlow() @@ -40,10 +48,13 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { private val _sentReports = MutableSharedFlow>>() val sentReport = _sentReports.asSharedFlow() - val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } + val chatRooms = state.map { it.rooms } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet()) + + val isSyncing = state.map { it.isSyncing } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - val isPartialProcessedGiftWrap = _state.map { it.isPartialProcessedGiftWrap } + val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) init { @@ -52,14 +63,14 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { // Observe message sync progress launch { - nostr.messages.messageSyncState.collect { state -> + nostr.messages.messageSyncState.collect { syncState -> // When at least some messages are processed, allow UI to show the list - if (state.processedCount > 0) { + if (syncState.processedCount > 0) { _state.update { it.copy(isPartialProcessedGiftWrap = true) } } // Refresh UI every 10 messages OR when sync is fully done - if (state.processedCount % 10 == 0 || !state.isSyncing) { + if (syncState.processedCount % 10 == 0 || !syncState.isSyncing) { refreshChatRooms() } } @@ -69,12 +80,16 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { launch { nostr.newEvents.collect { event -> val roomId = event.roomId() - val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId } + val existingRoom = _state.value.rooms.firstOrNull { it.id == roomId } if (existingRoom == null) { val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect val newRoom = Room.new(event, currentUser) - _chatRooms.update { (it + newRoom).sortedDescending().toSet() } + _state.update { + it.copy( + rooms = (it.rooms + newRoom).sortedDescending().toSet() + ) + } } else { updateRoomList(roomId, event) } @@ -105,7 +120,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { // Check if the room already exists val id = rumor.roomId() - val existingRoom = _chatRooms.value.firstOrNull { it.id == id } + val existingRoom = _state.value.rooms.firstOrNull { it.id == id } // If the room already exists, return its ID if (existingRoom != null) { @@ -116,9 +131,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { val room = Room.new(rumor, currentUser) // Update the chat rooms state - _chatRooms.update { currentRooms -> - (currentRooms + room).sortedDescending().toSet() - } + _state.update { it.copy(rooms = (it.rooms + room).sortedDescending().toSet()) } return room.id } catch (e: Exception) { @@ -127,20 +140,20 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { } fun getChatRoom(id: Long): Room? { - return chatRooms.value.firstOrNull { it.id == id } + return _state.value.rooms.firstOrNull { it.id == id } } suspend fun refreshChatRooms() { try { val rooms = nostr.messages.getChatRooms() ?: emptySet() - _chatRooms.update { currentRooms -> - val merged = currentRooms.associateBy { it.id }.toMutableMap() + _state.update { currentState -> + val merged = currentState.rooms.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() + currentState.copy(rooms = merged.values.sortedDescending().toSet()) } } catch (e: Exception) { showError("Error: ${e.message}") @@ -202,7 +215,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { if (file == null) return try { - val uri = blossomUpload(nostr.signer.get(), file, contentType) + val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType) if (uri != null) sendMessage(roomId, uri, replies) } catch (e: Exception) { throw IllegalArgumentException("Error: ${e.message}") @@ -221,8 +234,8 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { } private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) { - _chatRooms.update { currentRooms -> - currentRooms.map { room -> + _state.update { currentState -> + val updatedRooms = currentState.rooms.map { room -> if (room.id == roomId) { room.copy( lastMessage = newMessage.content(), @@ -232,13 +245,14 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { room } }.sortedDescending().toSet() + currentState.copy(rooms = updatedRooms) } } fun resetInternalState() { - _chatRooms.value = emptySet() _state.update { it.copy( + rooms = emptySet(), isPartialProcessedGiftWrap = false, ) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt index 4ab20c4..5d46875 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt @@ -2,7 +2,6 @@ package su.reya.coop.viewmodel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope @@ -21,7 +20,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import rust.nostr.sdk.PublicKey import rust.nostr.sdk.RelayMetadata @@ -29,6 +27,7 @@ import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.Timestamp import su.reya.coop.Profile import su.reya.coop.nostr.Nostr +import su.reya.coop.repository.MediaRepository import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -39,6 +38,8 @@ data class NostrAppState( ) class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { + private val mediaRepository = MediaRepository() + private val alwaysRunTasks = flow { coroutineScope { val observerJob = launch { runObserver() } @@ -92,17 +93,6 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { getCacheMetadata() } - override fun onCleared() { - super.onCleared() - - // Disconnect to all bootstrap relays - viewModelScope.launch { - withContext(NonCancellable) { - nostr.disconnect() - } - } - } - private fun reconnect() { viewModelScope.launch { nostr.waitUntilInitialized() @@ -248,7 +238,13 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { _appState.update { it.copy(isBusy = true) } try { val avatarUrl = - picture?.let { blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg") } + picture?.let { + mediaRepository.blossomUpload( + nostr.signer.get(), + it, + contentType ?: "image/jpeg" + ) + } val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl) val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")