chore: improve performance #41
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return when {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>(extraBufferCapacity = 10)
|
||||
val errorEvents = _errorEvents.asSharedFlow()
|
||||
|
||||
protected fun showError(message: String) {
|
||||
ErrorRepository.showError(message)
|
||||
_errorEvents.tryEmit(message)
|
||||
}
|
||||
}
|
||||
@@ -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<EventId> = 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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<NostrAppState> =
|
||||
combine(_appState, alwaysRunTasks) { state, _ -> state }.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = NostrAppState()
|
||||
)
|
||||
val appState: StateFlow<NostrAppState> = _appState.asStateFlow()
|
||||
|
||||
private val _contactList = MutableStateFlow<Set<PublicKey>>(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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user