From 5970acc88b17df2c0db5f22b2a24cc158a146c86 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 10 Jul 2026 08:41:44 +0700 Subject: [PATCH] clean up view models --- .../androidMain/kotlin/su/reya/coop/App.kt | 18 ++- .../kotlin/su/reya/coop/MainActivity.kt | 8 +- .../su/reya/coop/screens/ImportScreen.kt | 42 +++--- .../su/reya/coop/screens/OnboardingScreen.kt | 37 ++++-- .../su/reya/coop/screens/chat/ChatScreen.kt | 22 ++-- .../su/reya/coop/shared/ProfileEditor.kt | 2 +- .../reya/coop/repository/ErrorRepository.kt | 13 -- .../su/reya/coop/viewmodel/AuthViewModel.kt | 124 +++++++++++------- .../su/reya/coop/viewmodel/BaseViewModel.kt | 8 +- .../su/reya/coop/viewmodel/ChatViewModel.kt | 22 ++-- .../su/reya/coop/viewmodel/NostrViewModel.kt | 83 +++++------- 11 files changed, 209 insertions(+), 170 deletions(-) delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/repository/ErrorRepository.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index d2b4f17..80ce0a5 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -34,7 +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 kotlinx.coroutines.launch import su.reya.coop.screens.chat.ChatScreen import su.reya.coop.screens.ContactListScreen import su.reya.coop.screens.HomeScreen @@ -118,8 +118,20 @@ fun App( } LaunchedEffect(Unit) { - ErrorRepository.errors.collect { message -> - snackbarHostState.showSnackbar(message) + launch { + authViewModel.errorEvents.collect { message -> + snackbarHostState.showSnackbar(message) + } + } + launch { + chatViewModel.errorEvents.collect { message -> + snackbarHostState.showSnackbar(message) + } + } + launch { + nostrViewModel.errorEvents.collect { message -> + snackbarHostState.showSnackbar(message) + } } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index d56a596..b453bcc 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -13,6 +13,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import su.reya.coop.nostr.NostrManager +import su.reya.coop.repository.MediaRepository import su.reya.coop.viewmodel.AuthViewModel import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.NostrViewModel @@ -26,12 +27,13 @@ class MainActivity : ComponentActivity() { private val factory by lazy { object : ViewModelProvider.Factory { private val storage = AppStore(this@MainActivity) - private val nostrViewModel = NostrViewModel(NostrManager.instance) - private val chatViewModel = ChatViewModel(NostrManager.instance) + private val mediaRepository = MediaRepository() + private val nostrViewModel = NostrViewModel(NostrManager.instance, mediaRepository) + private val chatViewModel = ChatViewModel(NostrManager.instance, mediaRepository) private val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher) private val authViewModel = - AuthViewModel(NostrManager.instance, storage, androidSigner) + AuthViewModel(NostrManager.instance, storage, mediaRepository, androidSigner) override fun create(modelClass: Class): T { return when { 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 967b712..c435a79 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -44,10 +43,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.PasswordVisualTransformation 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_scanner -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.Keys import rust.nostr.sdk.NostrConnectUri @@ -65,12 +64,12 @@ fun ImportScreen() { val qrScanResult = LocalScanResult.current val focusManager = LocalFocusManager.current val authViewModel = LocalAuthViewModel.current - val scope = rememberCoroutineScope() + + val authState by authViewModel.state.collectAsStateWithLifecycle() var secret by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } var requirePassword by remember { mutableStateOf(false) } - var loading by remember { mutableStateOf(false) } LaunchedEffect(qrScanResult.content) { qrScanResult.content?.let { result -> @@ -98,6 +97,20 @@ fun ImportScreen() { } } + // Navigate to Home on successful import (signerRequired becomes false) + LaunchedEffect(authState.signerRequired) { + if (authState.signerRequired == false) { + navigator.navigate(Screen.Home) + } + } + + // Show import errors via snackbar + LaunchedEffect(authState.importError) { + authState.importError?.let { + snackbarHostState.showSnackbar(it) + } + } + Scaffold( containerColor = MaterialTheme.colorScheme.surfaceContainer, snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -164,7 +177,7 @@ fun ImportScreen() { BasicTextField( value = secret, onValueChange = { secret = it }, - enabled = !loading, + enabled = !authState.isImporting, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions( @@ -209,7 +222,7 @@ fun ImportScreen() { BasicTextField( value = password, onValueChange = { password = it }, - enabled = !loading && requirePassword, + enabled = !authState.isImporting && requirePassword, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions( @@ -237,25 +250,14 @@ fun ImportScreen() { Spacer(modifier = Modifier.size(16.dp)) Button( onClick = { - scope.launch { - loading = true - try { - // Import the identity - authViewModel.importIdentity(secret, password) - // Navigate to the home screen - navigator.navigate(Screen.Home) - } catch (e: Exception) { - snackbarHostState.showSnackbar(e.message ?: "Error") - loading = false - } - } + authViewModel.importIdentity(secret, password) }, modifier = Modifier .fillMaxWidth() .height(ButtonDefaults.MediumContainerHeight), - enabled = secret.isNotBlank() && !loading, + enabled = secret.isNotBlank() && !authState.isImporting, ) { - if (loading) { + if (authState.isImporting) { LoadingIndicator() } else { Text( 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 a042cd7..e0dad00 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -24,6 +24,8 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -44,6 +46,7 @@ import androidx.core.net.toUri import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.coop import kotlinx.coroutines.launch +import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.painterResource import su.reya.coop.LocalAuthViewModel import su.reya.coop.LocalNavigator @@ -58,8 +61,24 @@ fun OnboardingScreen() { val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current val authViewModel = LocalAuthViewModel.current + + val authState by authViewModel.state.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() + // Navigate to Home on successful external signer connection + LaunchedEffect(authState.signerRequired) { + if (authState.signerRequired == false) { + navigator.navigate(Screen.Home) + } + } + + // Show connection errors + LaunchedEffect(authState.importError) { + authState.importError?.let { + snackbarHostState.showSnackbar(it) + } + } + val logoPainter = painterResource(Res.drawable.coop) val expressiveFont = getExpressiveFontFamily() @@ -157,18 +176,12 @@ fun OnboardingScreen() { Spacer(modifier = Modifier.size(8.dp)) FilledTonalButton( onClick = { - scope.launch { - if (authViewModel.isExternalSignerAvailable()) { - try { - // Connect to the external signer - // TODO: show all available signers? - authViewModel.connectExternalSigner() - // Navigate to the home screen - navigator.navigate(Screen.Home) - } catch (e: Exception) { - e.message?.let { snackbarHostState.showSnackbar(it) } - } - } else { + if (authViewModel.isExternalSignerAvailable()) { + // Connect to the external signer + // TODO: show all available signers? + authViewModel.connectExternalSigner() + } else { + scope.launch { val result = snackbarHostState.showSnackbar( message = "External signer not installed. Please install Amber or alternatives.", actionLabel = "Install", diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt index 2468f46..afd4ea7 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt @@ -120,20 +120,16 @@ fun ChatScreen(id: Long, screening: Boolean = false) { val sendFile = { uri: Uri -> scope.launch { - try { - // Read file - val file = withContext(Dispatchers.IO) { - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - } - - // Parse the file content type - val type = context.contentResolver.getType(uri) - - // Send message - chatViewModel.sendFileMessage(id, file, type) - } catch (e: Exception) { - snackbarHostState.showSnackbar("Error: ${e.message}") + // Read file on IO dispatcher + val file = withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } } + + // Parse the file content type + val type = context.contentResolver.getType(uri) + + // Send message (handles errors internally via ViewModel) + chatViewModel.sendFileMessage(id, file, type) } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/ProfileEditor.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/ProfileEditor.kt index b3827fb..e975f85 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/ProfileEditor.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/ProfileEditor.kt @@ -69,7 +69,7 @@ fun ProfileEditor( initialBio: String = "", initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL) onBack: () -> Unit, - onConfirm: suspend (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit + onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit ) { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current diff --git a/shared/src/commonMain/kotlin/su/reya/coop/repository/ErrorRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/ErrorRepository.kt deleted file mode 100644 index 8116902..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/repository/ErrorRepository.kt +++ /dev/null @@ -1,13 +0,0 @@ -package su.reya.coop.repository - -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.receiveAsFlow - -object ErrorRepository { - private val _errors = Channel(Channel.BUFFERED) - val errors = _errors.receiveAsFlow() - - fun showError(message: String) { - _errors.trySend(message) - } -} \ No newline at end of file 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 0492606..5250bb9 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt @@ -23,15 +23,16 @@ import kotlin.time.Duration.Companion.seconds data class AuthState( val signerRequired: Boolean? = null, val isNotificationBannerDismissed: Boolean = false, + val isImporting: Boolean = false, + val importError: String? = null, ) class AuthViewModel( private val nostr: Nostr, private val storage: AppStorage, + private val mediaRepository: MediaRepository, 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" @@ -161,65 +162,98 @@ class AuthViewModel( } } - suspend fun importIdentity(secret: String, password: String? = null) { - val (signer, decryptedSecret) = createSigner(secret, password) - // Update signer - nostr.setSigner(signer) - // Persist the secret in the secret storage - storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret) - // Update local states - _state.update { it.copy(signerRequired = false) } + fun importIdentity(secret: String, password: String? = null) { + viewModelScope.launch { + _state.update { it.copy(isImporting = true, importError = null) } + try { + val (signer, decryptedSecret) = createSigner(secret, password) + // Update signer + nostr.setSigner(signer) + // Persist the secret in the secret storage + storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret) + // Update local states + _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) } + } + } } - suspend fun connectExternalSigner() { - val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") + 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 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 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 - storage.setSecret(KEY_USER_SIGNER, "nip55://${result.packageName}/${result.pubkey.toHex()}") - // Update local states - _state.update { it.copy(signerRequired = false) } + // Update signer + nostr.setSigner(signer) + // Store the signer in the secret storage + storage.setSecret( + KEY_USER_SIGNER, + "nip55://${result.packageName}/${result.pubkey.toHex()}" + ) + // Update local states + _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 isExternalSignerAvailable(): Boolean { return externalSignerHandler?.isAvailable() == true } - suspend fun createIdentity( + fun createIdentity( name: String, bio: String?, picture: ByteArray?, contentType: String? = null ) { - val keys = Keys.generate() - val secret = keys.secretKey().toBech32() - val avatarUrl = picture?.let { - mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg") + 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") + } + // Create identity + nostr.profiles.createIdentity( + keys = keys, + name = name, + bio = bio, + picture = avatarUrl + ) + // Persist the secret in the secret storage + storage.setSecret(KEY_USER_SIGNER, secret) + // Update local states + _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) } + } } - // Create identity - nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl) - // Persist the secret in the secret storage - storage.setSecret(KEY_USER_SIGNER, secret) - // Update local states - _state.update { it.copy(signerRequired = false) } } } 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 e1b0c13..5ce2118 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt @@ -1,10 +1,14 @@ package su.reya.coop.viewmodel import androidx.lifecycle.ViewModel -import su.reya.coop.repository.ErrorRepository +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow abstract class BaseViewModel : ViewModel() { + private val _errorEvents = MutableSharedFlow(extraBufferCapacity = 10) + val errorEvents = _errorEvents.asSharedFlow() + protected fun showError(message: String) { - ErrorRepository.showError(message) + _errorEvents.tryEmit(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 31071fb..dda430a 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt @@ -29,9 +29,10 @@ data class ChatState( val isPartialProcessedGiftWrap: Boolean = false, ) -class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { - private val mediaRepository = MediaRepository() - +class ChatViewModel( + private val nostr: Nostr, + private val mediaRepository: MediaRepository, +) : BaseViewModel() { private val _state = MutableStateFlow(ChatState()) val state = combine( _state, @@ -175,6 +176,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { fun sendMessage(roomId: Long, message: String, replies: List = emptyList()) { if (message.isEmpty()) { showError("Message cannot be empty") + return } viewModelScope.launch { try { @@ -195,7 +197,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { } } - suspend fun sendFileMessage( + fun sendFileMessage( roomId: Long, file: ByteArray?, contentType: String? = "image/jpeg", @@ -203,11 +205,13 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { ) { if (file == null) return - try { - val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType) - if (uri != null) sendMessage(roomId, uri, replies) - } catch (e: Exception) { - throw IllegalArgumentException("Error: ${e.message}") + 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}") + } } } 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 f6f1c91..e112c83 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.awaitCancellation import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay @@ -10,11 +9,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine 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.stateIn @@ -39,30 +36,12 @@ data class NostrAppState( val isRelayListEmpty: Boolean = false, ) -class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { - private val mediaRepository = MediaRepository() - - private val alwaysRunTasks = flow { - coroutineScope { - val observerJob = launch { runObserver() } - val batchingJob = launch { runMetadataBatching() } - try { - emit(Unit) - awaitCancellation() - } finally { - observerJob.cancel() - batchingJob.cancel() - } - } - } - +class NostrViewModel( + private val nostr: Nostr, + private val mediaRepository: MediaRepository, +) : BaseViewModel() { private val _appState = MutableStateFlow(NostrAppState()) - val appState: StateFlow = - combine(_appState, alwaysRunTasks) { state, _ -> state }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = NostrAppState() - ) + val appState: StateFlow = _appState.asStateFlow() private val _contactList = MutableStateFlow>(emptySet()) val contactList = _contactList.asStateFlow() @@ -83,6 +62,10 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) init { + // Launch continuous background observers + viewModelScope.launch { runObserver() } + viewModelScope.launch { runMetadataBatching() } + // Automatically reconnect bootstrap relays reconnect() @@ -116,7 +99,7 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { } } - private suspend fun runMetadataBatching() = coroutineScope { + private suspend fun runMetadataBatching() { // Wait until the client is ready nostr.waitUntilInitialized() @@ -192,9 +175,7 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { private fun requestMetadata(pubkey: PublicKey) { if (seenPublicKeys.add(pubkey)) { - viewModelScope.launch { - metadataRequestChannel.send(pubkey) - } + metadataRequestChannel.trySend(pubkey) } } @@ -224,32 +205,36 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { _appState.update { it.copy(isRelayListEmpty = false) } } - suspend fun updateProfile( + fun updateProfile( name: String? = null, bio: String? = null, picture: ByteArray? = null, contentType: String? = null ) { - _appState.update { it.copy(isBusy = true) } - try { - val avatarUrl = - 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") + viewModelScope.launch { + _appState.update { it.copy(isBusy = true) } + try { + val avatarUrl = + 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") - // Update the metadata state after successfully published - updateMetadata(currentUser, Profile(currentUser, newMetadata)) + // Update the metadata state after successfully published + updateMetadata(currentUser, Profile(currentUser, newMetadata)) - // Update local state - _appState.update { it.copy(isBusy = false) } - } catch (e: Exception) { - showError("Error: ${e.message}") + // Update local state + _appState.update { it.copy(isBusy = false) } + } catch (e: Exception) { + showError("Error: ${e.message}") + _appState.update { it.copy(isBusy = false) } + } } }