clean up view models

This commit is contained in:
2026-07-10 08:41:44 +07:00
parent 8ea66d1769
commit 5970acc88b
11 changed files with 209 additions and 170 deletions

View File

@@ -34,7 +34,7 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay 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.chat.ChatScreen
import su.reya.coop.screens.ContactListScreen import su.reya.coop.screens.ContactListScreen
import su.reya.coop.screens.HomeScreen import su.reya.coop.screens.HomeScreen
@@ -118,10 +118,22 @@ fun App(
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
ErrorRepository.errors.collect { message -> launch {
authViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message) snackbarHostState.showSnackbar(message)
} }
} }
launch {
chatViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
launch {
nostrViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
}
LaunchedEffect(activity) { LaunchedEffect(activity) {
activity?.let { activity?.let {

View File

@@ -13,6 +13,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import su.reya.coop.nostr.NostrManager import su.reya.coop.nostr.NostrManager
import su.reya.coop.repository.MediaRepository
import su.reya.coop.viewmodel.AuthViewModel import su.reya.coop.viewmodel.AuthViewModel
import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.NostrViewModel import su.reya.coop.viewmodel.NostrViewModel
@@ -26,12 +27,13 @@ class MainActivity : ComponentActivity() {
private val factory by lazy { private val factory by lazy {
object : ViewModelProvider.Factory { object : ViewModelProvider.Factory {
private val storage = AppStore(this@MainActivity) private val storage = AppStore(this@MainActivity)
private val nostrViewModel = NostrViewModel(NostrManager.instance) private val mediaRepository = MediaRepository()
private val chatViewModel = ChatViewModel(NostrManager.instance) private val nostrViewModel = NostrViewModel(NostrManager.instance, mediaRepository)
private val chatViewModel = ChatViewModel(NostrManager.instance, mediaRepository)
private val androidSigner = private val androidSigner =
AndroidExternalSigner(this@MainActivity, externalSignerLauncher) AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
private val authViewModel = private val authViewModel =
AuthViewModel(NostrManager.instance, storage, androidSigner) AuthViewModel(NostrManager.instance, storage, mediaRepository, androidSigner)
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when { return when {

View File

@@ -34,7 +34,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_scanner import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Keys import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.NostrConnectUri
@@ -65,12 +64,12 @@ fun ImportScreen() {
val qrScanResult = LocalScanResult.current val qrScanResult = LocalScanResult.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val authViewModel = LocalAuthViewModel.current val authViewModel = LocalAuthViewModel.current
val scope = rememberCoroutineScope()
val authState by authViewModel.state.collectAsStateWithLifecycle()
var secret by remember { mutableStateOf("") } var secret by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
var requirePassword by remember { mutableStateOf(false) } var requirePassword by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(false) }
LaunchedEffect(qrScanResult.content) { LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result -> 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( Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -164,7 +177,7 @@ fun ImportScreen() {
BasicTextField( BasicTextField(
value = secret, value = secret,
onValueChange = { secret = it }, onValueChange = { secret = it },
enabled = !loading, enabled = !authState.isImporting,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
@@ -209,7 +222,7 @@ fun ImportScreen() {
BasicTextField( BasicTextField(
value = password, value = password,
onValueChange = { password = it }, onValueChange = { password = it },
enabled = !loading && requirePassword, enabled = !authState.isImporting && requirePassword,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
@@ -237,25 +250,14 @@ fun ImportScreen() {
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
Button( Button(
onClick = { onClick = {
scope.launch {
loading = true
try {
// Import the identity
authViewModel.importIdentity(secret, password) authViewModel.importIdentity(secret, password)
// Navigate to the home screen
navigator.navigate(Screen.Home)
} catch (e: Exception) {
snackbarHostState.showSnackbar(e.message ?: "Error")
loading = false
}
}
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight), .height(ButtonDefaults.MediumContainerHeight),
enabled = secret.isNotBlank() && !loading, enabled = secret.isNotBlank() && !authState.isImporting,
) { ) {
if (loading) { if (authState.isImporting) {
LoadingIndicator() LoadingIndicator()
} else { } else {
Text( Text(

View File

@@ -24,6 +24,8 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset 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.Res
import coop.composeapp.generated.resources.coop import coop.composeapp.generated.resources.coop
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalAuthViewModel import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
@@ -58,8 +61,24 @@ fun OnboardingScreen() {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val authViewModel = LocalAuthViewModel.current val authViewModel = LocalAuthViewModel.current
val authState by authViewModel.state.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope() 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 logoPainter = painterResource(Res.drawable.coop)
val expressiveFont = getExpressiveFontFamily() val expressiveFont = getExpressiveFontFamily()
@@ -157,18 +176,12 @@ fun OnboardingScreen() {
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
FilledTonalButton( FilledTonalButton(
onClick = { onClick = {
scope.launch {
if (authViewModel.isExternalSignerAvailable()) { if (authViewModel.isExternalSignerAvailable()) {
try {
// Connect to the external signer // Connect to the external signer
// TODO: show all available signers? // TODO: show all available signers?
authViewModel.connectExternalSigner() authViewModel.connectExternalSigner()
// Navigate to the home screen
navigator.navigate(Screen.Home)
} catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) }
}
} else { } else {
scope.launch {
val result = snackbarHostState.showSnackbar( val result = snackbarHostState.showSnackbar(
message = "External signer not installed. Please install Amber or alternatives.", message = "External signer not installed. Please install Amber or alternatives.",
actionLabel = "Install", actionLabel = "Install",

View File

@@ -120,8 +120,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
val sendFile = { uri: Uri -> val sendFile = { uri: Uri ->
scope.launch { scope.launch {
try { // Read file on IO dispatcher
// Read file
val file = withContext(Dispatchers.IO) { val file = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() } context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
} }
@@ -129,11 +128,8 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
// Parse the file content type // Parse the file content type
val type = context.contentResolver.getType(uri) val type = context.contentResolver.getType(uri)
// Send message // Send message (handles errors internally via ViewModel)
chatViewModel.sendFileMessage(id, file, type) chatViewModel.sendFileMessage(id, file, type)
} catch (e: Exception) {
snackbarHostState.showSnackbar("Error: ${e.message}")
}
} }
} }

View File

@@ -69,7 +69,7 @@ fun ProfileEditor(
initialBio: String = "", initialBio: String = "",
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL) initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
onBack: () -> Unit, 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 context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current

View File

@@ -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<String>(Channel.BUFFERED)
val errors = _errors.receiveAsFlow()
fun showError(message: String) {
_errors.trySend(message)
}
}

View File

@@ -23,15 +23,16 @@ import kotlin.time.Duration.Companion.seconds
data class AuthState( data class AuthState(
val signerRequired: Boolean? = null, val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false, val isNotificationBannerDismissed: Boolean = false,
val isImporting: Boolean = false,
val importError: String? = null,
) )
class AuthViewModel( class AuthViewModel(
private val nostr: Nostr, private val nostr: Nostr,
private val storage: AppStorage, private val storage: AppStorage,
private val mediaRepository: MediaRepository,
private val externalSignerHandler: ExternalSignerHandler? = null, private val externalSignerHandler: ExternalSignerHandler? = null,
) : BaseViewModel() { ) : BaseViewModel() {
private val mediaRepository = MediaRepository()
companion object { companion object {
private const val KEY_USER_SIGNER = "user_signer" private const val KEY_USER_SIGNER = "user_signer"
private const val KEY_APP_KEYS = "app_keys" private const val KEY_APP_KEYS = "app_keys"
@@ -161,18 +162,30 @@ class AuthViewModel(
} }
} }
suspend fun importIdentity(secret: String, password: String? = null) { fun importIdentity(secret: String, password: String? = null) {
viewModelScope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val (signer, decryptedSecret) = createSigner(secret, password) val (signer, decryptedSecret) = createSigner(secret, password)
// Update signer // Update signer
nostr.setSigner(signer) nostr.setSigner(signer)
// Persist the secret in the secret storage // Persist the secret in the secret storage
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret) storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
// Update local states // Update local states
_state.update { it.copy(signerRequired = false) } _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() { fun connectExternalSigner() {
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") viewModelScope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val handler = externalSignerHandler
?: throw IllegalStateException("Signer not available")
val permissions = SignerPermissions.toJson( val permissions = SignerPermissions.toJson(
listOf( listOf(
@@ -195,31 +208,52 @@ class AuthViewModel(
// Update signer // Update signer
nostr.setSigner(signer) nostr.setSigner(signer)
// Store the signer in the secret storage // Store the signer in the secret storage
storage.setSecret(KEY_USER_SIGNER, "nip55://${result.packageName}/${result.pubkey.toHex()}") storage.setSecret(
KEY_USER_SIGNER,
"nip55://${result.packageName}/${result.pubkey.toHex()}"
)
// Update local states // Update local states
_state.update { it.copy(signerRequired = false) } _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 { fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true return externalSignerHandler?.isAvailable() == true
} }
suspend fun createIdentity( fun createIdentity(
name: String, name: String,
bio: String?, bio: String?,
picture: ByteArray?, picture: ByteArray?,
contentType: String? = null contentType: String? = null
) { ) {
viewModelScope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val keys = Keys.generate() val keys = Keys.generate()
val secret = keys.secretKey().toBech32() val secret = keys.secretKey().toBech32()
val avatarUrl = picture?.let { val avatarUrl = picture?.let {
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg") mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
} }
// Create identity // Create identity
nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl) nostr.profiles.createIdentity(
keys = keys,
name = name,
bio = bio,
picture = avatarUrl
)
// Persist the secret in the secret storage // Persist the secret in the secret storage
storage.setSecret(KEY_USER_SIGNER, secret) storage.setSecret(KEY_USER_SIGNER, secret)
// Update local states // Update local states
_state.update { it.copy(signerRequired = false) } _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) }
}
}
} }
} }

View File

@@ -1,10 +1,14 @@
package su.reya.coop.viewmodel package su.reya.coop.viewmodel
import androidx.lifecycle.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() { abstract class BaseViewModel : ViewModel() {
private val _errorEvents = MutableSharedFlow<String>(extraBufferCapacity = 10)
val errorEvents = _errorEvents.asSharedFlow()
protected fun showError(message: String) { protected fun showError(message: String) {
ErrorRepository.showError(message) _errorEvents.tryEmit(message)
} }
} }

View File

@@ -29,9 +29,10 @@ data class ChatState(
val isPartialProcessedGiftWrap: Boolean = false, val isPartialProcessedGiftWrap: Boolean = false,
) )
class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { class ChatViewModel(
private val mediaRepository = MediaRepository() private val nostr: Nostr,
private val mediaRepository: MediaRepository,
) : BaseViewModel() {
private val _state = MutableStateFlow(ChatState()) private val _state = MutableStateFlow(ChatState())
val state = combine( val state = combine(
_state, _state,
@@ -175,6 +176,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) { fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
if (message.isEmpty()) { if (message.isEmpty()) {
showError("Message cannot be empty") showError("Message cannot be empty")
return
} }
viewModelScope.launch { viewModelScope.launch {
try { try {
@@ -195,7 +197,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
} }
} }
suspend fun sendFileMessage( fun sendFileMessage(
roomId: Long, roomId: Long,
file: ByteArray?, file: ByteArray?,
contentType: String? = "image/jpeg", contentType: String? = "image/jpeg",
@@ -203,11 +205,13 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
) { ) {
if (file == null) return if (file == null) return
viewModelScope.launch {
try { try {
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType) val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
if (uri != null) sendMessage(roomId, uri, replies) if (uri != null) sendMessage(roomId, uri, replies)
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalArgumentException("Error: ${e.message}") showError("File upload failed: ${e.message}")
}
} }
} }

View File

@@ -2,7 +2,6 @@ package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -10,11 +9,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -39,30 +36,12 @@ data class NostrAppState(
val isRelayListEmpty: Boolean = false, val isRelayListEmpty: Boolean = false,
) )
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { class NostrViewModel(
private val mediaRepository = MediaRepository() private val nostr: Nostr,
private val mediaRepository: MediaRepository,
private val alwaysRunTasks = flow { ) : BaseViewModel() {
coroutineScope {
val observerJob = launch { runObserver() }
val batchingJob = launch { runMetadataBatching() }
try {
emit(Unit)
awaitCancellation()
} finally {
observerJob.cancel()
batchingJob.cancel()
}
}
}
private val _appState = MutableStateFlow(NostrAppState()) private val _appState = MutableStateFlow(NostrAppState())
val appState: StateFlow<NostrAppState> = val appState: StateFlow<NostrAppState> = _appState.asStateFlow()
combine(_appState, alwaysRunTasks) { state, _ -> state }.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = NostrAppState()
)
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet()) private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList = _contactList.asStateFlow() val contactList = _contactList.asStateFlow()
@@ -83,6 +62,10 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
init { init {
// Launch continuous background observers
viewModelScope.launch { runObserver() }
viewModelScope.launch { runMetadataBatching() }
// Automatically reconnect bootstrap relays // Automatically reconnect bootstrap relays
reconnect() 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 // Wait until the client is ready
nostr.waitUntilInitialized() nostr.waitUntilInitialized()
@@ -192,9 +175,7 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
private fun requestMetadata(pubkey: PublicKey) { private fun requestMetadata(pubkey: PublicKey) {
if (seenPublicKeys.add(pubkey)) { if (seenPublicKeys.add(pubkey)) {
viewModelScope.launch { metadataRequestChannel.trySend(pubkey)
metadataRequestChannel.send(pubkey)
}
} }
} }
@@ -224,12 +205,13 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
_appState.update { it.copy(isRelayListEmpty = false) } _appState.update { it.copy(isRelayListEmpty = false) }
} }
suspend fun updateProfile( fun updateProfile(
name: String? = null, name: String? = null,
bio: String? = null, bio: String? = null,
picture: ByteArray? = null, picture: ByteArray? = null,
contentType: String? = null contentType: String? = null
) { ) {
viewModelScope.launch {
_appState.update { it.copy(isBusy = true) } _appState.update { it.copy(isBusy = true) }
try { try {
val avatarUrl = val avatarUrl =
@@ -241,7 +223,8 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
) )
} }
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl) val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") val currentUser =
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
// Update the metadata state after successfully published // Update the metadata state after successfully published
updateMetadata(currentUser, Profile(currentUser, newMetadata)) updateMetadata(currentUser, Profile(currentUser, newMetadata))
@@ -250,6 +233,8 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
_appState.update { it.copy(isBusy = false) } _appState.update { it.copy(isBusy = false) }
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
_appState.update { it.copy(isBusy = false) }
}
} }
} }