From e0701504aa5f9b5ecd85349792f34d38d0923d24 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 9 Jul 2026 16:12:38 +0700 Subject: [PATCH 1/9] remove unnecessary query call --- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 2 +- .../commonMain/kotlin/su/reya/coop/nostr/Messaging.kt | 2 ++ .../kotlin/su/reya/coop/viewmodel/ChatViewModel.kt | 11 +++-------- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index f2fbe89..5c446a9 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -155,7 +155,7 @@ fun HomeScreen() { onPauseOrDispose { } } - LaunchedEffect(Unit) { + LaunchedEffect(authState.signerRequired) { chatViewModel.refreshChatRooms() } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt index dac7031..6031153 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt @@ -165,6 +165,8 @@ class MessageManager(private val nostr: Nostr) { val userPubkey = signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + println("pubkey: ${userPubkey.toBech32()}") + val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) val kTag = SingleLetterTag.lowercase(Alphabet.K) 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 baee6b6..94bbbb7 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt @@ -69,8 +69,8 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { _state.update { it.copy(isPartialProcessedGiftWrap = true) } } - // Refresh UI every 10 messages OR when sync is fully done - if (syncState.processedCount % 10 == 0 || !syncState.isSyncing) { + // Refresh UI every 100 messages OR when sync is fully done + if (syncState.processedCount % 100 == 0 || !syncState.isSyncing) { refreshChatRooms() } } @@ -97,9 +97,6 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { _newEvents.emit(event) } } - - // Initial load of rooms - refreshChatRooms() } } @@ -149,9 +146,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { _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 - } + rooms.forEach { room -> merged[room.id] = room } // Return as a sorted set to maintain UI consistency currentState.copy(rooms = merged.values.sortedDescending().toSet()) } -- 2.49.1 From 38b704fe1854c40bf589bb3c798402c0d3be03c3 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 9 Jul 2026 16:44:11 +0700 Subject: [PATCH 2/9] improve initial loading --- .../commonMain/kotlin/su/reya/coop/Room.kt | 3 +- .../kotlin/su/reya/coop/nostr/Messaging.kt | 41 ++++++++----------- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index e61a691..d06109b 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -41,8 +41,7 @@ data class Room( } companion object { - fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room { - val id = rumor.roomId() + fun new(rumor: UnsignedEvent, userPubkey: PublicKey, id: Long = rumor.roomId()): Room { val createdAt = rumor.createdAt() val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content() diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt index 6031153..e4f845e 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt @@ -164,40 +164,35 @@ class MessageManager(private val nostr: Nostr) { try { val userPubkey = signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") - - println("pubkey: ${userPubkey.toBech32()}") - + val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) val kTag = SingleLetterTag.lowercase(Alphabet.K) // Get all DM events val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm")) - val events = client?.database()?.query(filter) + val events = client?.database()?.query(filter)?.toVec() ?: return null // Collect rooms val roomsMap: MutableMap = mutableMapOf() events - ?.toVec() - ?.map { UnsignedEvent.fromJson(it.content()) } - ?.filter { it.tags().publicKeys().isNotEmpty() } - ?.forEach { event -> - val newRoom = Room.new(rumor = event, userPubkey = userPubkey) - val existingRoom = roomsMap[newRoom.id] + .map { UnsignedEvent.fromJson(it.content()) } + .filter { it.tags().publicKeys().isNotEmpty() } + .forEach { rumor -> + val id = rumor.roomId() + val isFromMe = rumor.author() == userPubkey + val existing = roomsMap[id] + val createdAt = rumor.createdAt() - // Check if the room already exists - if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) { - val rTag = SingleLetterTag.lowercase(Alphabet.R) - val filter = Filter().kind(kind).pubkey(userPubkey) - .customTag(rTag, newRoom.id.toString()) - - // Determine if it's an ongoing room - val isOngoing = - client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false - - // Append room to map - roomsMap[newRoom.id] = - if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom + // If the room is new or the current rumor is newer than the existing one + if (existing == null || createdAt.asSecs() > existing.createdAt.asSecs()) { + // A room is "Ongoing" if it was already marked as such or if the current rumor is from the user + val isOngoing = (existing?.kind == RoomKind.Ongoing) || isFromMe + val room = Room.new(rumor = rumor, userPubkey = userPubkey, id = id) + roomsMap[id] = if (isOngoing) room.copy(kind = RoomKind.Ongoing) else room + } else if (isFromMe && existing.kind != RoomKind.Ongoing) { + // If it's an older rumor but sent by the user, mark the room as Ongoing + roomsMap[id] = existing.copy(kind = RoomKind.Ongoing) } } -- 2.49.1 From 8ea66d1769245a06793f8cac9dadd9698de3bed9 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 9 Jul 2026 17:28:37 +0700 Subject: [PATCH 3/9] refactor --- .../su/reya/coop/viewmodel/ChatViewModel.kt | 47 +++++++------------ .../su/reya/coop/viewmodel/NostrViewModel.kt | 22 ++++----- 2 files changed, 29 insertions(+), 40 deletions(-) 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 94bbbb7..31071fb 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt @@ -24,7 +24,7 @@ import su.reya.coop.repository.MediaRepository import su.reya.coop.roomId data class ChatState( - val rooms: Set = emptySet(), + val rooms: Map = emptyMap(), val isSyncing: Boolean = false, val isPartialProcessedGiftWrap: Boolean = false, ) @@ -48,8 +48,8 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { private val _sentReports = MutableSharedFlow>>() val sentReport = _sentReports.asSharedFlow() - val chatRooms = state.map { it.rooms } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet()) + val chatRooms = state.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) val isSyncing = state.map { it.isSyncing } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) @@ -80,16 +80,12 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { launch { nostr.newEvents.collect { event -> val roomId = event.roomId() - val existingRoom = _state.value.rooms.firstOrNull { it.id == roomId } + val existingRoom = _state.value.rooms[roomId] if (existingRoom == null) { val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect val newRoom = Room.new(event, currentUser) - _state.update { - it.copy( - rooms = (it.rooms + newRoom).sortedDescending().toSet() - ) - } + _state.update { it.copy(rooms = it.rooms + (newRoom.id to newRoom)) } } else { updateRoomList(roomId, event) } @@ -117,7 +113,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { // Check if the room already exists val id = rumor.roomId() - val existingRoom = _state.value.rooms.firstOrNull { it.id == id } + val existingRoom = _state.value.rooms[id] // If the room already exists, return its ID if (existingRoom != null) { @@ -128,7 +124,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { val room = Room.new(rumor, currentUser) // Update the chat rooms state - _state.update { it.copy(rooms = (it.rooms + room).sortedDescending().toSet()) } + _state.update { it.copy(rooms = it.rooms + (room.id to room)) } return room.id } catch (e: Exception) { @@ -137,18 +133,16 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { } fun getChatRoom(id: Long): Room? { - return _state.value.rooms.firstOrNull { it.id == id } + return _state.value.rooms[id] } suspend fun refreshChatRooms() { try { val rooms = nostr.messages.getChatRooms() ?: emptySet() _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 - currentState.copy(rooms = merged.values.sortedDescending().toSet()) + val newMap = currentState.rooms.toMutableMap() + rooms.forEach { room -> newMap[room.id] = room } + currentState.copy(rooms = newMap) } } catch (e: Exception) { showError("Error: ${e.message}") @@ -230,24 +224,19 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) { _state.update { currentState -> - val updatedRooms = currentState.rooms.map { room -> - if (room.id == roomId) { - room.copy( - lastMessage = newMessage.content(), - createdAt = newMessage.createdAt() - ) - } else { - room - } - }.sortedDescending().toSet() - currentState.copy(rooms = updatedRooms) + val room = currentState.rooms[roomId] ?: return@update currentState + val updatedRoom = room.copy( + lastMessage = newMessage.content(), + createdAt = newMessage.createdAt() + ) + currentState.copy(rooms = currentState.rooms + (roomId to updatedRoom)) } } fun resetInternalState() { _state.update { it.copy( - rooms = emptySet(), + rooms = emptyMap(), 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 4c02d66..f6f1c91 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt @@ -71,7 +71,7 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { private val profiles = mutableMapOf>() private val metadataRequestChannel = Channel(Channel.UNLIMITED) private val seenPublicKeys = mutableSetOf() - + val isRelayListEmpty = appState.map { it.isRelayListEmpty } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) @@ -158,12 +158,14 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { viewModelScope.launch { // Wait until the client is ready nostr.waitUntilInitialized() + val cache = nostr.profiles.getAllCacheMetadata() - nostr.profiles.getAllCacheMetadata().forEach { (pubkey, metadata) -> - // Update the metadata state - updateMetadata(pubkey, Profile(pubkey, metadata)) - // Update seenPublicKeys to avoid duplicate requests - seenPublicKeys.add(pubkey) + profilesMutex.withLock { + cache.forEach { (pubkey, metadata) -> + val profile = Profile(pubkey, metadata) + profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile + seenPublicKeys.add(pubkey) + } } } } @@ -196,11 +198,9 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { } } - private fun updateMetadata(pubkey: PublicKey, profile: Profile) { - viewModelScope.launch { - profilesMutex.withLock { - profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile - } + private suspend fun updateMetadata(pubkey: PublicKey, profile: Profile) { + profilesMutex.withLock { + profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile } } -- 2.49.1 From 5970acc88b17df2c0db5f22b2a24cc158a146c86 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 10 Jul 2026 08:41:44 +0700 Subject: [PATCH 4/9] 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) } + } } } -- 2.49.1 From 9a8d3c06fa8a95c34c8658fd1f9270b1c4f81015 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 10 Jul 2026 10:06:54 +0700 Subject: [PATCH 5/9] update --- .../kotlin/su/reya/coop/nostr/Signer.kt | 16 +++++++--------- .../su/reya/coop/viewmodel/ChatViewModel.kt | 3 +++ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt index 4872ac7..c3119fe 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt @@ -29,16 +29,14 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner { /** * Switch to a new signer. */ - suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock { - val pubkey = try { - withTimeoutOrNull(20.seconds) { - newSigner.getPublicKeyAsync() - } - } catch (e: Exception) { - throw IllegalStateException("Failed to get public key from signer", e) + suspend fun switch(newSigner: AsyncNostrSigner) { + val pubkey = withTimeoutOrNull(20.seconds) { newSigner.getPublicKeyAsync() } + ?: throw IllegalStateException("Failed to get public key from signer") + + mutex.withLock { + signer = newSigner + _publicKeyFlow.value = pubkey } - signer = newSigner - _publicKeyFlow.value = pubkey } override suspend fun getPublicKeyAsync(): PublicKey? { 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 dda430a..b780371 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt @@ -1,6 +1,7 @@ package su.reya.coop.viewmodel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -145,6 +146,8 @@ class ChatViewModel( rooms.forEach { room -> newMap[room.id] = room } currentState.copy(rooms = newMap) } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { showError("Error: ${e.message}") } -- 2.49.1 From 645430875ceb25a1e96159b0646fcf1312a6a1ee Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 10 Jul 2026 11:02:10 +0700 Subject: [PATCH 6/9] optimize flow usage --- .../su/reya/coop/screens/ContactListScreen.kt | 3 +- .../kotlin/su/reya/coop/nostr/Messaging.kt | 2 +- .../kotlin/su/reya/coop/nostr/Nostr.kt | 21 +++++--- .../su/reya/coop/nostr/ProfileManager.kt | 41 ++++++++++----- .../kotlin/su/reya/coop/nostr/RelayManager.kt | 52 +++++++++---------- .../su/reya/coop/viewmodel/BaseViewModel.kt | 7 ++- .../su/reya/coop/viewmodel/ChatViewModel.kt | 26 +++++----- 7 files changed, 87 insertions(+), 65 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt index 4f16cc4..4044658 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt @@ -33,7 +33,6 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -328,7 +327,7 @@ fun ContactListItem( ) { val nostrViewModel = LocalNostrViewModel.current val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } - val profile by profileFlow.collectAsState(initial = null) + val profile by profileFlow.collectAsStateWithLifecycle(initialValue = null) SegmentedListItem( onClick = onClick, diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt index e4f845e..a3e1d38 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt @@ -164,7 +164,7 @@ class MessageManager(private val nostr: Nostr) { try { val userPubkey = signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") - + val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) val kTag = SingleLetterTag.lowercase(Alphabet.K) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt index 330fd31..0ef8a3e 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt @@ -2,11 +2,13 @@ package su.reya.coop.nostr import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import rust.nostr.sdk.AsyncNostrSigner @@ -30,6 +32,7 @@ import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.initLogger import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds object NostrManager { val instance = Nostr() @@ -59,17 +62,18 @@ class Nostr { private val isInitialized = MutableStateFlow(false) - private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) + private val _newEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 100, + onBufferOverflow = BufferOverflow.SUSPEND + ) val newEvents = _newEvents.asSharedFlow() - suspend fun emitNewEvent(event: UnsignedEvent) { - _newEvents.emit(event) + fun emitNewEvent(event: UnsignedEvent) { + _newEvents.tryEmit(event) } - suspend fun init( - dbPath: String, - logLevel: LogLevel = LogLevel.WARN - ) { + suspend fun init(dbPath: String, logLevel: LogLevel = LogLevel.WARN) { try { if (isInitialized.value) return @@ -105,7 +109,8 @@ class Nostr { } suspend fun waitUntilInitialized() { - isInitialized.first { it } + withTimeoutOrNull(30.seconds) { isInitialized.first { it } } + ?: throw IllegalStateException("Nostr initialization timed out") } suspend fun connectBootstrapRelays() { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt index c276df9..7579420 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt @@ -4,6 +4,7 @@ import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.statement.HttpResponse +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import rust.nostr.sdk.AckPolicy @@ -33,10 +34,19 @@ class ProfileManager(private val nostr: Nostr) { private val signer: UniversalSigner get() = nostr.signer private val _metadataUpdates = - MutableSharedFlow>(extraBufferCapacity = 100) + MutableSharedFlow>( + replay = 0, + extraBufferCapacity = 100, + onBufferOverflow = BufferOverflow.SUSPEND + ) val metadataUpdates = _metadataUpdates.asSharedFlow() - private val _contactListUpdates = MutableSharedFlow>(extraBufferCapacity = 100) + private val _contactListUpdates = + MutableSharedFlow>( + replay = 0, + extraBufferCapacity = 100, + onBufferOverflow = BufferOverflow.SUSPEND + ) val contactListUpdates = _contactListUpdates.asSharedFlow() suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) { @@ -138,19 +148,24 @@ class ProfileManager(private val nostr: Nostr) { bio: String? = null, picture: String? = null ): Metadata { - val currentUser = - signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") - try { - val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord() - val newRecord = record.copy( - displayName = name ?: record.displayName, - about = bio ?: record.about, - picture = picture ?: record.picture - ) - val newMetadata = Metadata.fromRecord(newRecord) - val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer) + val currentUser = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + // Get the latest metadata event + val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord() + + // Build a new metadata based on old records + val newMetadata = Metadata.fromRecord( + record.copy( + displayName = name ?: record.displayName, + about = bio ?: record.about, + picture = picture ?: record.picture + ) + ) + + // Send the new metadata event + val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer) client?.sendEvent( event = event, target = SendEventTarget.broadcast(), diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt index d763018..6c7d57f 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt @@ -104,6 +104,32 @@ class RelayManager(private val nostr: Nostr) { return msgRelayList } + suspend fun getRelayList(publicKey: PublicKey): Map { + try { + val kind = Kind.fromStd(KindStandard.RELAY_LIST) + val filter = Filter().kind(kind).author(publicKey).limit(1u) + val events = client?.database()?.query(filter) + + return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap()) + } catch (e: Exception) { + throw IllegalStateException("Failed to get relay list: ${e.message}", e) + } + } + + suspend fun setRelaylist(relays: Map) { + try { + val event = EventBuilder.relayList(relays).finalizeAsync(signer) + + client?.sendEvent( + event = event, + target = SendEventTarget.broadcast(), + ackPolicy = AckPolicy.none(), + ) + } catch (e: Exception) { + throw IllegalStateException("Failed to set msg relays: ${e.message}", e) + } + } + suspend fun setMsgRelays(urls: List) { try { val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer) @@ -154,30 +180,4 @@ class RelayManager(private val nostr: Nostr) { throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e) } } - - suspend fun getRelayList(publicKey: PublicKey): Map { - try { - val kind = Kind.fromStd(KindStandard.RELAY_LIST) - val filter = Filter().kind(kind).author(publicKey).limit(1u) - val events = client?.database()?.query(filter) - - return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap()) - } catch (e: Exception) { - throw IllegalStateException("Failed to get relay list: ${e.message}", e) - } - } - - suspend fun setRelaylist(relays: Map) { - try { - val event = EventBuilder.relayList(relays).finalizeAsync(signer) - - client?.sendEvent( - event = event, - target = SendEventTarget.broadcast(), - ackPolicy = AckPolicy.none(), - ) - } catch (e: Exception) { - throw IllegalStateException("Failed to set msg relays: ${e.message}", e) - } - } } \ No newline at end of file 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 5ce2118..e4d17d4 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt @@ -1,11 +1,16 @@ package su.reya.coop.viewmodel import androidx.lifecycle.ViewModel +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow abstract class BaseViewModel : ViewModel() { - private val _errorEvents = MutableSharedFlow(extraBufferCapacity = 10) + private val _errorEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.SUSPEND + ) val errorEvents = _errorEvents.asSharedFlow() protected fun showError(message: String) { 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 b780371..aaf9dbd 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt @@ -2,11 +2,11 @@ package su.reya.coop.viewmodel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -16,7 +16,6 @@ import rust.nostr.sdk.EventId import rust.nostr.sdk.Kind import rust.nostr.sdk.KindStandard import rust.nostr.sdk.PublicKey -import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.Tag import rust.nostr.sdk.UnsignedEvent import su.reya.coop.Room @@ -26,7 +25,6 @@ import su.reya.coop.roomId data class ChatState( val rooms: Map = emptyMap(), - val isSyncing: Boolean = false, val isPartialProcessedGiftWrap: Boolean = false, ) @@ -35,25 +33,23 @@ class ChatViewModel( private val mediaRepository: MediaRepository, ) : BaseViewModel() { private val _state = MutableStateFlow(ChatState()) - val state = combine( - _state, - nostr.messages.messageSyncState - ) { local, state -> local.copy(isSyncing = state.isSyncing) }.stateIn( + val state = _state.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), ChatState() ) - private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) + private val _newEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 100, + onBufferOverflow = BufferOverflow.SUSPEND + ) val newEvents = _newEvents.asSharedFlow() - private val _sentReports = MutableSharedFlow>>() - val sentReport = _sentReports.asSharedFlow() - val chatRooms = state.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - val isSyncing = state.map { it.isSyncing } + val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap } @@ -92,7 +88,7 @@ class ChatViewModel( updateRoomList(roomId, event) } - _newEvents.emit(event) + _newEvents.tryEmit(event) } } } @@ -156,6 +152,8 @@ class ChatViewModel( suspend fun getChatRoomMessages(roomId: Long): List { try { return nostr.messages.getChatRoomMessages(roomId) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { showError("Error: ${e.message}") } @@ -191,7 +189,7 @@ class ChatViewModel( replies = replies, onRumorCreated = { event -> updateRoomList(roomId, event) - viewModelScope.launch { _newEvents.emit(event) } + viewModelScope.launch { _newEvents.tryEmit(event) } }, ) } catch (e: Exception) { -- 2.49.1 From c5b76e9b2f3a33a342e1d1974e3f8760990f908e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 10 Jul 2026 20:31:15 +0700 Subject: [PATCH 7/9] refactor --- .../androidMain/kotlin/su/reya/coop/App.kt | 12 + .../su/reya/coop/ExternalSignerLauncher.kt | 7 +- .../kotlin/su/reya/coop/MainActivity.kt | 5 + .../su/reya/coop/NostrForegroundService.kt | 3 + .../su/reya/coop/screens/ContactListScreen.kt | 6 +- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 32 +-- .../su/reya/coop/screens/NewChatScreen.kt | 14 +- .../su/reya/coop/screens/RelayScreen.kt | 80 +++--- .../su/reya/coop/screens/RequestListScreen.kt | 8 +- .../reya/coop/screens/chat/ChatComponents.kt | 17 +- .../su/reya/coop/screens/chat/ChatScreen.kt | 20 +- .../su/reya/coop/shared/ProfileEditor.kt | 6 +- .../kotlin/su/reya/coop/nostr/Messaging.kt | 15 +- .../kotlin/su/reya/coop/nostr/Nostr.kt | 7 +- .../reya/coop/repository/MediaRepository.kt | 3 + .../su/reya/coop/viewmodel/AuthViewModel.kt | 14 +- .../su/reya/coop/viewmodel/ChatViewModel.kt | 42 +-- .../su/reya/coop/viewmodel/NostrViewModel.kt | 252 +++++------------- .../su/reya/coop/viewmodel/RelayViewModel.kt | 188 +++++++++++++ 19 files changed, 430 insertions(+), 301 deletions(-) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodel/RelayViewModel.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 80ce0a5..1d16a8f 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -51,6 +51,7 @@ import su.reya.coop.screens.UpdateProfileScreen import su.reya.coop.viewmodel.AuthViewModel import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.NostrViewModel +import su.reya.coop.viewmodel.RelayViewModel val LocalNostrViewModel = staticCompositionLocalOf { error("No NostrViewModel provided") @@ -64,6 +65,10 @@ val LocalAuthViewModel = staticCompositionLocalOf { error("No AuthViewModel provided") } +val LocalRelayViewModel = staticCompositionLocalOf { + error("No RelayViewModel provided") +} + val LocalSnackbarHostState = staticCompositionLocalOf { error("No SnackbarHostState provided") } @@ -80,6 +85,7 @@ val LocalScanResult = staticCompositionLocalOf { @Composable fun App( nostrViewModel: NostrViewModel, + relayViewModel: RelayViewModel, chatViewModel: ChatViewModel, authViewModel: AuthViewModel, ) { @@ -133,6 +139,11 @@ fun App( snackbarHostState.showSnackbar(message) } } + launch { + relayViewModel.errorEvents.collect { message -> + snackbarHostState.showSnackbar(message) + } + } } LaunchedEffect(activity) { @@ -174,6 +185,7 @@ fun App( ) { CompositionLocalProvider( LocalNostrViewModel provides nostrViewModel, + LocalRelayViewModel provides relayViewModel, LocalChatViewModel provides chatViewModel, LocalAuthViewModel provides authViewModel, LocalSnackbarHostState provides snackbarHostState, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSignerLauncher.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSignerLauncher.kt index 51bbba7..c80f497 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSignerLauncher.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSignerLauncher.kt @@ -4,12 +4,15 @@ import android.content.Intent import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -class ExternalSignerLauncher { +class ExternalSignerLauncher( + private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main, +) { private var launcher: ActivityResultLauncher? = null private var pendingResult: CompletableDeferred? = null private val mutex = Mutex() @@ -19,7 +22,7 @@ class ExternalSignerLauncher { } suspend fun launch(intent: Intent): ActivityResult = mutex.withLock { - withContext(Dispatchers.Main) { + withContext(mainDispatcher) { val deferred = CompletableDeferred() pendingResult = deferred launcher?.launch(intent) ?: throw IllegalStateException("Signer not registered") diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index b453bcc..0699a27 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -17,6 +17,7 @@ import su.reya.coop.repository.MediaRepository import su.reya.coop.viewmodel.AuthViewModel import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.NostrViewModel +import su.reya.coop.viewmodel.RelayViewModel import kotlin.system.exitProcess class MainActivity : ComponentActivity() { @@ -29,6 +30,7 @@ class MainActivity : ComponentActivity() { private val storage = AppStore(this@MainActivity) private val mediaRepository = MediaRepository() private val nostrViewModel = NostrViewModel(NostrManager.instance, mediaRepository) + private val relayViewModel = RelayViewModel(NostrManager.instance) private val chatViewModel = ChatViewModel(NostrManager.instance, mediaRepository) private val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher) @@ -38,6 +40,7 @@ class MainActivity : ComponentActivity() { override fun create(modelClass: Class): T { return when { modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel + modelClass.isAssignableFrom(RelayViewModel::class.java) -> relayViewModel modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel else -> throw IllegalArgumentException("Unknown ViewModel class") @@ -47,6 +50,7 @@ class MainActivity : ComponentActivity() { } private val nostrViewModel: NostrViewModel by viewModels { factory } + private val relayViewModel: RelayViewModel by viewModels { factory } private val chatViewModel: ChatViewModel by viewModels { factory } private val authViewModel: AuthViewModel by viewModels { factory } @@ -93,6 +97,7 @@ class MainActivity : ComponentActivity() { setContent { App( nostrViewModel = nostrViewModel, + relayViewModel = relayViewModel, chatViewModel = chatViewModel, authViewModel = authViewModel, ) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt index 76ebe5b..be04f39 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt @@ -14,6 +14,7 @@ import androidx.core.app.NotificationCompat import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.ProcessLifecycleOwner +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -27,6 +28,8 @@ private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES" class NostrForegroundService : Service() { private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + var ioDispatcher: CoroutineDispatcher = Dispatchers.IO private val nostr by lazy { NostrManager.instance } private var notificationJob: Job? = null diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt index 4044658..ceb8f3d 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt @@ -265,10 +265,8 @@ fun AddContactDialog(onDismissRequest: () -> Unit) { }, actions = { IconButton(onClick = { - scope.launch { - val success = nostrViewModel.addContact(contact) - if (success) onDismissRequest() - } + nostrViewModel.addContact(contact) + onDismissRequest() }) { Icon( painter = painterResource(Res.drawable.ic_check), diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 5c446a9..29ef04f 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -93,6 +93,7 @@ import su.reya.coop.LocalAuthViewModel import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalRelayViewModel import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Room @@ -114,6 +115,7 @@ fun HomeScreen() { val nostrViewModel = LocalNostrViewModel.current val chatViewModel = LocalChatViewModel.current val authViewModel = LocalAuthViewModel.current + val relayViewModel = LocalRelayViewModel.current val scope = rememberCoroutineScope() val sheetState = rememberModalBottomSheetState(true) @@ -123,7 +125,7 @@ fun HomeScreen() { val userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() - val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle() + val isRelayListEmpty by relayViewModel.isRelayListEmpty.collectAsStateWithLifecycle() val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle() val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() @@ -319,11 +321,9 @@ fun HomeScreen() { isRefreshing = isRefreshing, state = pullToRefreshState, onRefresh = { - scope.launch { - isRefreshing = true - chatViewModel.refreshChatRooms() - isRefreshing = false - } + isRefreshing = true + chatViewModel.refreshChatRooms() + isRefreshing = false }, indicator = { PullToRefreshDefaults.LoadingIndicator( @@ -469,7 +469,7 @@ fun HomeScreen() { // Show the relay setup dialog if the msg relay list is empty if (isRelayListEmpty) { ModalBottomSheet( - onDismissRequest = { nostrViewModel.dismissRelayWarning() }, + onDismissRequest = { relayViewModel.dismissRelayWarning() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surfaceContainer, ) { @@ -573,15 +573,9 @@ fun HomeScreen() { TextButton( enabled = !isBusy, onClick = { - scope.launch { - isBusy = true - try { - nostrViewModel.refetchMsgRelays() - } catch (e: Exception) { - snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}") - } - isBusy = false - } + isBusy = true + relayViewModel.refetchMsgRelays() + isBusy = false }, modifier = Modifier .weight(1f) @@ -595,10 +589,8 @@ fun HomeScreen() { Button( enabled = !isBusy, onClick = { - scope.launch { - nostrViewModel.useDefaultMsgRelayList() - sheetState.hide() - } + relayViewModel.useDefaultMsgRelayList() + scope.launch { sheetState.hide() } }, modifier = Modifier .weight(1f) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt index 2c2fb10..1986ded 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt @@ -96,14 +96,16 @@ fun NewChatScreen() { selectedReceivers.add(pubkey) } } else if (query.contains("@")) { - val pubkey = nostrViewModel.searchByAddress(query) - if (pubkey != null) { - selectedReceivers.add(pubkey) + nostrViewModel.searchByAddress(query) { pubkey -> + if (pubkey != null) { + selectedReceivers.add(pubkey) + } } } else { - val results = nostrViewModel.searchByNostr(query) - searchResults.clear() - searchResults.addAll(results) + nostrViewModel.searchByNostr(query) { results -> + searchResults.clear() + searchResults.addAll(results) + } } query = "" diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt index 37515e8..27431e3 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +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_check @@ -67,14 +68,16 @@ import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayUrl import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalRelayViewModel import su.reya.coop.LocalSnackbarHostState @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun RelayScreen() { val navigator = LocalNavigator.current - val snackbarHostState = LocalSnackbarHostState.current val nostrViewModel = LocalNostrViewModel.current + val relayViewModel = LocalRelayViewModel.current + val snackbarHostState = LocalSnackbarHostState.current val scope = rememberCoroutineScope() val msgRelayList = remember { mutableStateListOf() } @@ -96,8 +99,25 @@ fun RelayScreen() { var relayToDelete by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - relayList.putAll(nostrViewModel.currentUserRelayList()) - msgRelayList.addAll(nostrViewModel.currentUserMsgRelayList()) + relayViewModel.loadCurrentUserRelayList() + relayViewModel.loadCurrentUserMsgRelayList() + } + + val loadedRelayList by relayViewModel.currentUserRelayList.collectAsStateWithLifecycle() + val loadedMsgRelayList by relayViewModel.currentUserMsgRelayList.collectAsStateWithLifecycle() + + LaunchedEffect(loadedRelayList) { + if (loadedRelayList.isNotEmpty()) { + relayList.clear() + relayList.putAll(loadedRelayList) + } + } + + LaunchedEffect(loadedMsgRelayList) { + if (loadedMsgRelayList.isNotEmpty()) { + msgRelayList.clear() + msgRelayList.addAll(loadedMsgRelayList) + } } Scaffold( @@ -314,20 +334,16 @@ fun RelayScreen() { confirmButton = { TextButton( onClick = { - scope.launch { - if (msgRelayList.size == 1) { + if (msgRelayList.size == 1) { + scope.launch { snackbarHostState.showSnackbar("You must have at least one relay") - relayToDelete = null - return@launch - } - try { - nostrViewModel.removeMsgRelay(relayToDelete!!) - msgRelayList.removeIf { it.toString() == relayToDelete } - relayToDelete = null - } catch (e: Exception) { - snackbarHostState.showSnackbar("Failed to remove relay: ${e.message}") } + relayToDelete = null + return@TextButton } + relayViewModel.removeMsgRelay(relayToDelete!!) + msgRelayList.removeIf { it.toString() == relayToDelete } + relayToDelete = null } ) { Text("Confirm") @@ -349,7 +365,7 @@ fun AddRelayDialog( onMsgRelayAdded: (newRelay: String) -> Unit, onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit, ) { - val nostrViewModel = LocalNostrViewModel.current + val relayViewModel = LocalRelayViewModel.current val snackbarHostState = LocalSnackbarHostState.current val scope = rememberCoroutineScope() @@ -397,26 +413,24 @@ fun AddRelayDialog( }, actions = { IconButton(onClick = { - scope.launch { - if (!isError) { - when (selected) { - "Messaging" -> { - nostrViewModel.addMsgRelay(relayAddress) - onMsgRelayAdded(relayAddress) - } - - "Inbox" -> { - nostrViewModel.addInboxRelay(relayAddress) - onRelayAdded(relayAddress, RelayMetadata.WRITE) - } - - "Outbox" -> { - nostrViewModel.addOutboxRelay(relayAddress) - onRelayAdded(relayAddress, RelayMetadata.READ) - } + if (!isError) { + when (selected) { + "Messaging" -> { + relayViewModel.addMsgRelay(relayAddress) + onMsgRelayAdded(relayAddress) + } + + "Inbox" -> { + relayViewModel.addInboxRelay(relayAddress) + onRelayAdded(relayAddress, RelayMetadata.WRITE) + } + + "Outbox" -> { + relayViewModel.addOutboxRelay(relayAddress) + onRelayAdded(relayAddress, RelayMetadata.READ) } - onDismissRequest() } + onDismissRequest() } }) { Icon( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt index 4580f5f..3951605 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt @@ -101,11 +101,9 @@ fun RequestListScreen() { isRefreshing = isRefreshing, state = pullToRefreshState, onRefresh = { - scope.launch { - isRefreshing = true - chatViewModel.refreshChatRooms() - isRefreshing = false - } + isRefreshing = true + chatViewModel.refreshChatRooms() + isRefreshing = false }, indicator = { PullToRefreshDefaults.LoadingIndicator( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt index ede3bf9..f68f373 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt @@ -18,7 +18,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 @@ -29,7 +28,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_cancel import coop.composeapp.generated.resources.ic_check_circle -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey import rust.nostr.sdk.Timestamp @@ -46,7 +44,6 @@ fun ScreenerCard(room: Room) { val pubkey = room.members.firstOrNull() ?: return val nostrViewModel = LocalNostrViewModel.current - val scope = rememberCoroutineScope() var isContact by remember { mutableStateOf(false) } var mutualContacts by remember { mutableStateOf>(emptySet()) } @@ -56,14 +53,12 @@ fun ScreenerCard(room: Room) { val profile by profileFlow.collectAsStateWithLifecycle() LaunchedEffect(pubkey) { - scope.launch { - // Check contact - nostrViewModel.verifyContact(pubkey).let { isContact = it } - // Get mutual contacts - nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it } - // Get the last activity - nostrViewModel.verifyActivity(pubkey)?.let { lastActivity = it } - } + // Check contact + nostrViewModel.verifyContact(pubkey) { isContact = it } + // Get mutual contacts + nostrViewModel.mutualContacts(pubkey) { mutualContacts = it } + // Get the last activity + nostrViewModel.verifyActivity(pubkey) { lastActivity = it } } Column( 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 afd4ea7..0f603ec 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 @@ -59,6 +59,7 @@ 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 kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -77,7 +78,11 @@ import su.reya.coop.shared.Avatar @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun ChatScreen(id: Long, screening: Boolean = false) { +fun ChatScreen( + id: Long, + screening: Boolean = false, + coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current @@ -121,7 +126,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { val sendFile = { uri: Uri -> scope.launch { // Read file on IO dispatcher - val file = withContext(Dispatchers.IO) { + val file = withContext(coroutineDispatcher) { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } } @@ -149,12 +154,13 @@ fun ChatScreen(id: Long, screening: Boolean = false) { LaunchedEffect(id) { // Get messages - val initialMessages = chatViewModel.getChatRoomMessages(id) - messages.clear() - messages.addAll(initialMessages) + chatViewModel.loadChatRoomMessages(id) { initialMessages -> + messages.clear() + messages.addAll(initialMessages) - // Stop loading spinner - loading = false + // Stop loading spinner + loading = false + } // Get msg relays for each member chatViewModel.chatRoomConnect(id) 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 e975f85..f40e07a 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/ProfileEditor.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/ProfileEditor.kt @@ -54,6 +54,7 @@ import coil3.compose.AsyncImage import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_plus +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -69,7 +70,8 @@ fun ProfileEditor( initialBio: String = "", initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL) onBack: () -> Unit, - onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit + onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current @@ -269,7 +271,7 @@ fun ProfileEditor( scope.launch { isBusy = true try { - val bytes = withContext(Dispatchers.IO) { + val bytes = withContext(ioDispatcher) { (picture as? Uri)?.let { context.contentResolver.openInputStream(it)?.readBytes() } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt index a3e1d38..1b86a7b 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt @@ -1,5 +1,6 @@ package su.reya.coop.nostr +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -119,7 +120,9 @@ class MessageManager(private val nostr: Nostr) { setCachedRumor(event.id(), unsignedEvent) return unsignedEvent - } catch (e: Throwable) { + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}") return null } @@ -131,7 +134,9 @@ class MessageManager(private val nostr: Nostr) { val event = client?.database()?.query(filter)?.first() return event?.content()?.let { UnsignedEvent.fromJson(it).ensureId() } - } catch (e: Throwable) { + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { throw IllegalStateException("Failed to get cached rumor: ${e.message}", e) } } @@ -155,7 +160,9 @@ class MessageManager(private val nostr: Nostr) { .finalizeAsync(Keys.generate()) client?.database()?.saveEvent(event) - } catch (e: Throwable) { + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { println("Failed to set cached rumor: ${e.message}") } } @@ -197,6 +204,8 @@ class MessageManager(private val nostr: Nostr) { } return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { println("Failed to get chat rooms: ${e.message}") return null diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt index 0ef8a3e..27d4031 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt @@ -1,5 +1,6 @@ package su.reya.coop.nostr +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.BufferOverflow @@ -50,7 +51,9 @@ object NostrManager { val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY } -class Nostr { +class Nostr( + private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, +) { var client: Client? = null private set var signer: UniversalSigner = UniversalSigner(Keys.generate()) @@ -164,7 +167,7 @@ class Nostr { var processedCount = 0 var eoseReceived = false - launch(Dispatchers.Default) { + launch(defaultDispatcher) { for (event in giftWrapQueue) { val rumor = messages.extractRumor(event) processedCount++ diff --git a/shared/src/commonMain/kotlin/su/reya/coop/repository/MediaRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/MediaRepository.kt index 05fe570..82380f4 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/repository/MediaRepository.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/MediaRepository.kt @@ -3,6 +3,7 @@ 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.coroutines.CancellationException import kotlinx.serialization.json.Json import rust.nostr.sdk.AsyncNostrSigner import su.reya.coop.blossom.BlossomClient @@ -31,6 +32,8 @@ class MediaRepository { signer = signer, ) descriptor?.url + } catch (e: CancellationException) { + throw e } 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 5250bb9..967c327 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt @@ -1,10 +1,13 @@ package su.reya.coop.viewmodel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.EncryptedSecretKey @@ -32,6 +35,7 @@ class AuthViewModel( private val storage: AppStorage, private val mediaRepository: MediaRepository, private val externalSignerHandler: ExternalSignerHandler? = null, + private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, ) : BaseViewModel() { companion object { private const val KEY_USER_SIGNER = "user_signer" @@ -112,21 +116,21 @@ class AuthViewModel( } } - private suspend fun getOrInitAppKeys(): Keys { + private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) { val secret = storage.getSecret(KEY_APP_KEYS) // If app keys are already stored, use them - if (secret != null) return Keys.parse(secret) + if (secret != null) return@withContext Keys.parse(secret) // Generate new app keys and save to the secret storage val keys = Keys.generate() storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32()) - return keys + keys } private suspend fun createSigner( secret: String, password: String? = null - ): Pair { - return when { + ): Pair = withContext(defaultDispatcher) { + when { secret.startsWith("nsec1") -> Keys.parse(secret) to null secret.startsWith("ncryptsec1") -> { 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 aaf9dbd..8ea4735 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt @@ -134,31 +134,33 @@ class ChatViewModel( return _state.value.rooms[id] } - suspend fun refreshChatRooms() { - try { - val rooms = nostr.messages.getChatRooms() ?: emptySet() - _state.update { currentState -> - val newMap = currentState.rooms.toMutableMap() - rooms.forEach { room -> newMap[room.id] = room } - currentState.copy(rooms = newMap) + fun refreshChatRooms() { + viewModelScope.launch { + try { + val rooms = nostr.messages.getChatRooms() ?: emptySet() + _state.update { currentState -> + val newMap = currentState.rooms.toMutableMap() + rooms.forEach { room -> newMap[room.id] = room } + currentState.copy(rooms = newMap) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + showError("Error: ${e.message}") } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - showError("Error: ${e.message}") } } - suspend fun getChatRoomMessages(roomId: Long): List { - try { - return nostr.messages.getChatRoomMessages(roomId) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - showError("Error: ${e.message}") + fun loadChatRoomMessages(roomId: Long, onResult: (List) -> Unit) { + viewModelScope.launch { + try { + onResult(nostr.messages.getChatRoomMessages(roomId)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + showError("Error: ${e.message}") + } } - - return emptyList() } fun chatRoomConnect(roomId: Long) { 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 e112c83..1174745 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -13,7 +12,6 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -21,19 +19,15 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull import rust.nostr.sdk.PublicKey -import rust.nostr.sdk.RelayMetadata -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 data class NostrAppState( val isBusy: Boolean = false, - val isRelayListEmpty: Boolean = false, ) class NostrViewModel( @@ -51,9 +45,6 @@ class NostrViewModel( private val metadataRequestChannel = Channel(Channel.UNLIMITED) private val seenPublicKeys = mutableSetOf() - val isRelayListEmpty = appState.map { it.isRelayListEmpty } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - @OptIn(ExperimentalCoroutinesApi::class) val currentUserProfile = nostr.signer.publicKeyFlow .flatMapLatest { pubkey -> @@ -69,8 +60,8 @@ class NostrViewModel( // Automatically reconnect bootstrap relays reconnect() - // Observe the signer state and verify the relay list - observeSignerAndCheckRelays() + // Fetch metadata for the current user + fetchUserMetadata() // Get all local stored metadata getCacheMetadata() @@ -153,7 +144,7 @@ class NostrViewModel( } } - private fun observeSignerAndCheckRelays() { + private fun fetchUserMetadata() { viewModelScope.launch { // Wait until the client is ready nostr.waitUntilInitialized() @@ -163,13 +154,6 @@ class NostrViewModel( // Get all metadata for the current user nostr.profiles.getUserMetadata() - - // Small delay to ensure all relays are connected - delay(2.seconds) - - // Check if the relay list is empty - val relays = nostr.relays.getMsgRelays(currentUser) - if (relays.isEmpty()) _appState.update { it.copy(isRelayListEmpty = true) } } } @@ -194,15 +178,6 @@ class NostrViewModel( fun resetInternalState() { _contactList.value = emptySet() - _appState.update { - it.copy( - isRelayListEmpty = false, - ) - } - } - - fun dismissRelayWarning() { - _appState.update { it.copy(isRelayListEmpty = false) } } fun updateProfile( @@ -238,131 +213,36 @@ class NostrViewModel( } } - suspend fun refetchMsgRelays() { - val currentUser = nostr.signer.getPublicKeyAsync() ?: return - val relays = nostr.relays.fetchMsgRelays(currentUser) - - if (relays.isNotEmpty()) dismissRelayWarning() - } - - suspend fun useDefaultMsgRelayList() { - try { - val defaultRelays = nostr.relays.getDefaultMsgRelayList() - nostr.relays.setMsgRelays(defaultRelays) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - - suspend fun currentUserRelayList(): Map { - try { - val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - return nostr.relays.getRelayList(currentUser) - } catch (e: Exception) { - showError("Error: ${e.message}") - return emptyMap() - } - } - - suspend fun addInboxRelay(relay: String) { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserRelayList().toMutableMap() - relays[relayUrl] = RelayMetadata.WRITE - - nostr.relays.setRelaylist(relays) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - - suspend fun addOutboxRelay(relay: String) { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserRelayList().toMutableMap() - relays[relayUrl] = RelayMetadata.READ - - nostr.relays.setRelaylist(relays) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - - suspend fun removeRelay(relay: String) { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserRelayList().toMutableMap() - relays.remove(relayUrl) - - nostr.relays.setRelaylist(relays) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - - suspend fun currentUserMsgRelayList(): List { - try { - val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - return nostr.relays.getMsgRelays(currentUser) - } catch (e: Exception) { - showError("Error: ${e.message}") - return emptyList() - } - } - - suspend fun addMsgRelay(relay: String) { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserMsgRelayList().toMutableSet() - relays.add(relayUrl) - - nostr.relays.setMsgRelays(relays.toList()) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - - suspend fun removeMsgRelay(relay: String) { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserMsgRelayList().toMutableSet() - relays.remove(relayUrl) - - nostr.relays.setMsgRelays(relays.toList()) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - - private suspend fun newContact(publicKey: PublicKey) { + private fun newContact(publicKey: PublicKey) { if (publicKey in contactList.value) return - try { - val updated = contactList.value + publicKey - // Publish new event - nostr.profiles.setContactList(updated.toList()) - // Optimistic local update - _contactList.update { it + publicKey } - } catch (e: Exception) { - showError("Error: ${e.message}") + viewModelScope.launch { + try { + val updated = contactList.value + publicKey + // Publish new event + nostr.profiles.setContactList(updated.toList()) + // Optimistic local update + _contactList.update { it + publicKey } + } catch (e: Exception) { + showError("Error: ${e.message}") + } } } - suspend fun addContact(address: String): Boolean { - val pubkey = try { - if (address.contains("@")) { - nostr.profiles.searchByAddress(address) - } else { - PublicKey.parse(address) + fun addContact(address: String) { + viewModelScope.launch { + val pubkey = try { + if (address.contains("@")) { + nostr.profiles.searchByAddress(address) + } else { + PublicKey.parse(address) + } + } catch (e: Exception) { + showError("Invalid contact address: ${e.message}") + return@launch } - } catch (e: Exception) { - showError("Invalid contact address: ${e.message}") - return false - } - return run { newContact(pubkey) - true } } @@ -382,48 +262,58 @@ class NostrViewModel( } } - suspend fun searchByAddress(query: String): PublicKey? { - try { - return nostr.profiles.searchByAddress(query) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - return null - } - - suspend fun searchByNostr(query: String): List { - try { - return nostr.profiles.searchByNostr(query) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - return emptyList() - } - - suspend fun verifyActivity(pubkey: PublicKey): Timestamp? { - return try { - nostr.profiles.verifyActivity(pubkey) - } catch (e: Exception) { - showError("Error: ${e.message}") - null + fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) { + viewModelScope.launch { + try { + onResult(nostr.profiles.searchByAddress(query)) + } catch (e: Exception) { + showError("Error: ${e.message}") + onResult(null) + } } } - suspend fun verifyContact(pubkey: PublicKey): Boolean { - return try { - nostr.profiles.verifyContact(pubkey) - } catch (e: Exception) { - showError("Error: ${e.message}") - false + fun searchByNostr(query: String, onResult: (List) -> Unit) { + viewModelScope.launch { + try { + onResult(nostr.profiles.searchByNostr(query)) + } catch (e: Exception) { + showError("Error: ${e.message}") + onResult(emptyList()) + } } } - suspend fun mutualContacts(pubkey: PublicKey): Set { - return try { - nostr.profiles.mutualContacts(pubkey) - } catch (e: Exception) { - showError("Error: ${e.message}") - setOf() + fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) { + viewModelScope.launch { + try { + onResult(nostr.profiles.verifyActivity(pubkey)) + } catch (e: Exception) { + showError("Error: ${e.message}") + onResult(null) + } + } + } + + fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) { + viewModelScope.launch { + try { + onResult(nostr.profiles.verifyContact(pubkey)) + } catch (e: Exception) { + showError("Error: ${e.message}") + onResult(false) + } + } + } + + fun mutualContacts(pubkey: PublicKey, onResult: (Set) -> Unit) { + viewModelScope.launch { + try { + onResult(nostr.profiles.mutualContacts(pubkey)) + } catch (e: Exception) { + showError("Error: ${e.message}") + onResult(emptySet()) + } } } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/RelayViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/RelayViewModel.kt new file mode 100644 index 0000000..f89e298 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/RelayViewModel.kt @@ -0,0 +1,188 @@ +package su.reya.coop.viewmodel + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import rust.nostr.sdk.RelayMetadata +import rust.nostr.sdk.RelayUrl +import su.reya.coop.nostr.Nostr +import kotlin.time.Duration.Companion.seconds + +class RelayViewModel( + private val nostr: Nostr, +) : BaseViewModel() { + private val _isRelayListEmpty = MutableStateFlow(false) + val isRelayListEmpty: StateFlow = _isRelayListEmpty.asStateFlow() + + private val _currentUserRelayList = MutableStateFlow>(emptyMap()) + val currentUserRelayList = _currentUserRelayList.asStateFlow() + + private val _currentUserMsgRelayList = MutableStateFlow>(emptyList()) + val currentUserMsgRelayList = _currentUserMsgRelayList.asStateFlow() + + init { + checkRelayList() + } + + private fun checkRelayList() { + viewModelScope.launch { + // Wait until the client is ready + nostr.waitUntilInitialized() + + // Wait until a signer is explicitly set (which updates publicKeyFlow) + val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first() + + // Small delay to ensure all relays are connected + delay(2.seconds) + + // Check if the relay list is empty + val relays = nostr.relays.getMsgRelays(currentUser) + if (relays.isEmpty()) _isRelayListEmpty.value = true + } + } + + fun dismissRelayWarning() { + _isRelayListEmpty.value = false + } + + fun resetInternalState() { + _isRelayListEmpty.value = false + } + + fun refetchMsgRelays() { + viewModelScope.launch { + val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch + val relays = nostr.relays.fetchMsgRelays(currentUser) + + if (relays.isNotEmpty()) dismissRelayWarning() + } + } + + fun useDefaultMsgRelayList() { + viewModelScope.launch { + try { + val defaultRelays = nostr.relays.getDefaultMsgRelayList() + nostr.relays.setMsgRelays(defaultRelays) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun loadCurrentUserRelayList() { + viewModelScope.launch { + try { + val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + _currentUserRelayList.value = nostr.relays.getRelayList(currentUser) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + private suspend fun currentUserRelayListInternal(): Map { + try { + val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + return nostr.relays.getRelayList(currentUser) + } catch (e: Exception) { + showError("Error: ${e.message}") + return emptyMap() + } + } + + fun addInboxRelay(relay: String) { + viewModelScope.launch { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserRelayListInternal().toMutableMap() + relays[relayUrl] = RelayMetadata.WRITE + + nostr.relays.setRelaylist(relays) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun addOutboxRelay(relay: String) { + viewModelScope.launch { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserRelayListInternal().toMutableMap() + relays[relayUrl] = RelayMetadata.READ + + nostr.relays.setRelaylist(relays) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun removeRelay(relay: String) { + viewModelScope.launch { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserRelayListInternal().toMutableMap() + relays.remove(relayUrl) + + nostr.relays.setRelaylist(relays) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun loadCurrentUserMsgRelayList() { + viewModelScope.launch { + try { + val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + _currentUserMsgRelayList.value = nostr.relays.getMsgRelays(currentUser) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + private suspend fun currentUserMsgRelayListInternal(): List { + try { + val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + return nostr.relays.getMsgRelays(currentUser) + } catch (e: Exception) { + showError("Error: ${e.message}") + return emptyList() + } + } + + fun addMsgRelay(relay: String) { + viewModelScope.launch { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserMsgRelayListInternal().toMutableSet() + relays.add(relayUrl) + + nostr.relays.setMsgRelays(relays.toList()) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun removeMsgRelay(relay: String) { + viewModelScope.launch { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserMsgRelayListInternal().toMutableSet() + relays.remove(relayUrl) + + nostr.relays.setMsgRelays(relays.toList()) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } +} -- 2.49.1 From f2c1587efadf164fc5065730d954ba2ef00f9f19 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 11 Jul 2026 16:41:39 +0700 Subject: [PATCH 8/9] refactor --- .../androidMain/kotlin/su/reya/coop/App.kt | 29 +-- .../kotlin/su/reya/coop/MainActivity.kt | 21 +- .../su/reya/coop/screens/ContactListScreen.kt | 9 +- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 30 ++- .../su/reya/coop/screens/ImportScreen.kt | 24 +-- .../kotlin/su/reya/coop/screens/MyQrScreen.kt | 6 +- .../su/reya/coop/screens/NewChatScreen.kt | 8 +- .../su/reya/coop/screens/NewIdentityScreen.kt | 6 +- .../su/reya/coop/screens/OnboardingScreen.kt | 18 +- .../su/reya/coop/screens/RelayScreen.kt | 22 +- .../reya/coop/screens/UpdateProfileScreen.kt | 8 +- .../reya/coop/screens/chat/ChatComponents.kt | 8 +- .../su/reya/coop/screens/chat/ChatScreen.kt | 4 +- .../su/reya/coop/viewmodel/NostrViewModel.kt | 197 +----------------- .../AccountAuthDelegate.kt} | 74 +++---- .../account/AccountContactDelegate.kt | 137 ++++++++++++ .../account/AccountProfileDelegate.kt | 81 +++++++ .../AccountRelayDelegate.kt} | 81 ++++--- .../viewmodel/account/AccountViewModel.kt | 146 +++++++++++++ 19 files changed, 528 insertions(+), 381 deletions(-) rename shared/src/commonMain/kotlin/su/reya/coop/viewmodel/{AuthViewModel.kt => account/AccountAuthDelegate.kt} (81%) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountContactDelegate.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountProfileDelegate.kt rename shared/src/commonMain/kotlin/su/reya/coop/viewmodel/{RelayViewModel.kt => account/AccountRelayDelegate.kt} (73%) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountViewModel.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 1d16a8f..b4787cf 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -35,7 +35,6 @@ import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import kotlinx.coroutines.launch -import su.reya.coop.screens.chat.ChatScreen import su.reya.coop.screens.ContactListScreen import su.reya.coop.screens.HomeScreen import su.reya.coop.screens.ImportScreen @@ -48,10 +47,10 @@ import su.reya.coop.screens.RelayScreen import su.reya.coop.screens.RequestListScreen import su.reya.coop.screens.ScanScreen import su.reya.coop.screens.UpdateProfileScreen -import su.reya.coop.viewmodel.AuthViewModel +import su.reya.coop.screens.chat.ChatScreen import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.NostrViewModel -import su.reya.coop.viewmodel.RelayViewModel +import su.reya.coop.viewmodel.account.AccountViewModel val LocalNostrViewModel = staticCompositionLocalOf { error("No NostrViewModel provided") @@ -61,13 +60,10 @@ val LocalChatViewModel = staticCompositionLocalOf { error("No ChatViewModel provided") } -val LocalAuthViewModel = staticCompositionLocalOf { - error("No AuthViewModel provided") +val LocalAccountViewModel = staticCompositionLocalOf { + error("No AccountViewModel provided") } -val LocalRelayViewModel = staticCompositionLocalOf { - error("No RelayViewModel provided") -} val LocalSnackbarHostState = staticCompositionLocalOf { error("No SnackbarHostState provided") @@ -85,9 +81,8 @@ val LocalScanResult = staticCompositionLocalOf { @Composable fun App( nostrViewModel: NostrViewModel, - relayViewModel: RelayViewModel, chatViewModel: ChatViewModel, - authViewModel: AuthViewModel, + accountViewModel: AccountViewModel, ) { val context = LocalContext.current val activity = context as? ComponentActivity @@ -96,8 +91,8 @@ fun App( val qrScanResult = remember { QrScanResult() } // Get the signer required state - val authState by authViewModel.state.collectAsStateWithLifecycle() - val signerRequired = authState.signerRequired + val accountState by accountViewModel.state.collectAsStateWithLifecycle() + val signerRequired = accountState.signerRequired // Snackbar val snackbarHostState = remember { SnackbarHostState() } @@ -125,7 +120,7 @@ fun App( LaunchedEffect(Unit) { launch { - authViewModel.errorEvents.collect { message -> + accountViewModel.errorEvents.collect { message -> snackbarHostState.showSnackbar(message) } } @@ -139,11 +134,6 @@ fun App( snackbarHostState.showSnackbar(message) } } - launch { - relayViewModel.errorEvents.collect { message -> - snackbarHostState.showSnackbar(message) - } - } } LaunchedEffect(activity) { @@ -185,9 +175,8 @@ fun App( ) { CompositionLocalProvider( LocalNostrViewModel provides nostrViewModel, - LocalRelayViewModel provides relayViewModel, LocalChatViewModel provides chatViewModel, - LocalAuthViewModel provides authViewModel, + LocalAccountViewModel provides accountViewModel, LocalSnackbarHostState provides snackbarHostState, LocalNavigator provides navigator, LocalScanResult provides qrScanResult, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index 0699a27..3c73159 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -14,10 +14,9 @@ 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 -import su.reya.coop.viewmodel.RelayViewModel +import su.reya.coop.viewmodel.account.AccountViewModel import kotlin.system.exitProcess class MainActivity : ComponentActivity() { @@ -29,20 +28,18 @@ class MainActivity : ComponentActivity() { object : ViewModelProvider.Factory { private val storage = AppStore(this@MainActivity) private val mediaRepository = MediaRepository() - private val nostrViewModel = NostrViewModel(NostrManager.instance, mediaRepository) - private val relayViewModel = RelayViewModel(NostrManager.instance) + private val nostrViewModel = NostrViewModel(NostrManager.instance) private val chatViewModel = ChatViewModel(NostrManager.instance, mediaRepository) private val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher) - private val authViewModel = - AuthViewModel(NostrManager.instance, storage, mediaRepository, androidSigner) + private val accountViewModel = + AccountViewModel(NostrManager.instance, storage, mediaRepository, androidSigner) override fun create(modelClass: Class): T { return when { modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel - modelClass.isAssignableFrom(RelayViewModel::class.java) -> relayViewModel modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel - modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel + modelClass.isAssignableFrom(AccountViewModel::class.java) -> accountViewModel else -> throw IllegalArgumentException("Unknown ViewModel class") } as T } @@ -50,9 +47,8 @@ class MainActivity : ComponentActivity() { } private val nostrViewModel: NostrViewModel by viewModels { factory } - private val relayViewModel: RelayViewModel by viewModels { factory } private val chatViewModel: ChatViewModel by viewModels { factory } - private val authViewModel: AuthViewModel by viewModels { factory } + private val accountViewModel: AccountViewModel by viewModels { factory } override fun onCreate(savedInstanceState: Bundle?) { Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> @@ -91,15 +87,14 @@ class MainActivity : ComponentActivity() { // Keep the splash screen visible until the signer check is complete splashScreen.setKeepOnScreenCondition { - authViewModel.state.value.signerRequired == null + accountViewModel.state.value.signerRequired == null } setContent { App( nostrViewModel = nostrViewModel, - relayViewModel = relayViewModel, chatViewModel = chatViewModel, - authViewModel = authViewModel, + accountViewModel = accountViewModel, ) } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt index ceb8f3d..e20f1df 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt @@ -58,6 +58,7 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.Nip05Address import rust.nostr.sdk.PublicKey +import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel @@ -72,9 +73,10 @@ fun ContactListScreen() { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current val nostrViewModel = LocalNostrViewModel.current + val accountViewModel = LocalAccountViewModel.current val chatViewModel = LocalChatViewModel.current - val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle() + val contactList by accountViewModel.contactList.collectAsStateWithLifecycle() var openAddContactDialog by remember { mutableStateOf(false) } var contactToDelete by remember { mutableStateOf(null) } @@ -201,7 +203,7 @@ fun ContactListScreen() { confirmButton = { TextButton( onClick = { - contactToDelete?.let { nostrViewModel.removeContact(it) } + contactToDelete?.let { accountViewModel.removeContact(it) } contactToDelete = null } ) { @@ -222,6 +224,7 @@ fun ContactListScreen() { fun AddContactDialog(onDismissRequest: () -> Unit) { val snackbarHostState = LocalSnackbarHostState.current val nostrViewModel = LocalNostrViewModel.current + val accountViewModel = LocalAccountViewModel.current val focusRequester = remember { FocusRequester() } var contact by remember { mutableStateOf("") } var isError by remember { mutableStateOf(false) } @@ -265,7 +268,7 @@ fun AddContactDialog(onDismissRequest: () -> Unit) { }, actions = { IconButton(onClick = { - nostrViewModel.addContact(contact) + accountViewModel.addContact(contact) onDismissRequest() }) { Icon( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 29ef04f..bc35c80 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -89,11 +89,10 @@ import coop.composeapp.generated.resources.ic_scanner import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey -import su.reya.coop.LocalAuthViewModel +import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel -import su.reya.coop.LocalRelayViewModel import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Room @@ -114,23 +113,22 @@ fun HomeScreen() { val clipboardManager = LocalClipboard.current val nostrViewModel = LocalNostrViewModel.current val chatViewModel = LocalChatViewModel.current - val authViewModel = LocalAuthViewModel.current - val relayViewModel = LocalRelayViewModel.current + val accountViewModel = LocalAccountViewModel.current val scope = rememberCoroutineScope() val sheetState = rememberModalBottomSheetState(true) val listState = rememberLazyListState() val pullToRefreshState = rememberPullToRefreshState() - val userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() + val userProfile by accountViewModel.currentUserProfile.collectAsStateWithLifecycle() val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() - val isRelayListEmpty by relayViewModel.isRelayListEmpty.collectAsStateWithLifecycle() + val isRelayListEmpty by accountViewModel.isRelayListEmpty.collectAsStateWithLifecycle() val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle() val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() - val authState by authViewModel.state.collectAsStateWithLifecycle() - val isBannerDismissed = authState.isNotificationBannerDismissed + val accountState by accountViewModel.state.collectAsStateWithLifecycle() + val isBannerDismissed = accountState.isNotificationBannerDismissed val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } var showBottomSheet by remember { mutableStateOf(false) } @@ -157,7 +155,7 @@ fun HomeScreen() { onPauseOrDispose { } } - LaunchedEffect(authState.signerRequired) { + LaunchedEffect(accountState.signerRequired) { chatViewModel.refreshChatRooms() } @@ -282,7 +280,7 @@ fun HomeScreen() { horizontalArrangement = Arrangement.spacedBy(8.dp), ) { TextButton( - onClick = { authViewModel.dismissNotificationBanner() }, + onClick = { accountViewModel.dismissNotificationBanner() }, modifier = Modifier.weight(1f), ) { Text(text = "Maybe later") @@ -469,7 +467,7 @@ fun HomeScreen() { // Show the relay setup dialog if the msg relay list is empty if (isRelayListEmpty) { ModalBottomSheet( - onDismissRequest = { relayViewModel.dismissRelayWarning() }, + onDismissRequest = { accountViewModel.dismissRelayWarning() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surfaceContainer, ) { @@ -574,7 +572,7 @@ fun HomeScreen() { enabled = !isBusy, onClick = { isBusy = true - relayViewModel.refetchMsgRelays() + accountViewModel.refetchMsgRelays() isBusy = false }, modifier = Modifier @@ -589,7 +587,7 @@ fun HomeScreen() { Button( enabled = !isBusy, onClick = { - relayViewModel.useDefaultMsgRelayList() + accountViewModel.useDefaultMsgRelayList() scope.launch { sheetState.hide() } }, modifier = Modifier @@ -738,7 +736,7 @@ fun BottomMenuList( val navigator = LocalNavigator.current val nostrViewModel = LocalNostrViewModel.current val chatViewModel = LocalChatViewModel.current - val authViewModel = LocalAuthViewModel.current + val accountViewModel = LocalAccountViewModel.current val defaultMenuList = listOf( "Update Profile" to { navigator.navigate(Screen.UpdateProfile) }, @@ -770,8 +768,8 @@ fun BottomMenuList( FilledTonalButton( onClick = { onDismiss { - authViewModel.logout(onLogout = { - nostrViewModel.resetInternalState() + accountViewModel.logout(onLogout = { + accountViewModel.resetInternalState() chatViewModel.resetInternalState() }) } 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 c435a79..9960a9f 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -50,7 +50,7 @@ import coop.composeapp.generated.resources.ic_scanner import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.Keys import rust.nostr.sdk.NostrConnectUri -import su.reya.coop.LocalAuthViewModel +import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState @@ -63,9 +63,9 @@ fun ImportScreen() { val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current val focusManager = LocalFocusManager.current - val authViewModel = LocalAuthViewModel.current + val accountViewModel = LocalAccountViewModel.current - val authState by authViewModel.state.collectAsStateWithLifecycle() + val accountState by accountViewModel.state.collectAsStateWithLifecycle() var secret by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } @@ -98,15 +98,15 @@ fun ImportScreen() { } // Navigate to Home on successful import (signerRequired becomes false) - LaunchedEffect(authState.signerRequired) { - if (authState.signerRequired == false) { + LaunchedEffect(accountState.signerRequired) { + if (accountState.signerRequired == false) { navigator.navigate(Screen.Home) } } // Show import errors via snackbar - LaunchedEffect(authState.importError) { - authState.importError?.let { + LaunchedEffect(accountState.importError) { + accountState.importError?.let { snackbarHostState.showSnackbar(it) } } @@ -177,7 +177,7 @@ fun ImportScreen() { BasicTextField( value = secret, onValueChange = { secret = it }, - enabled = !authState.isImporting, + enabled = !accountState.isImporting, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions( @@ -222,7 +222,7 @@ fun ImportScreen() { BasicTextField( value = password, onValueChange = { password = it }, - enabled = !authState.isImporting && requirePassword, + enabled = !accountState.isImporting && requirePassword, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions( @@ -250,14 +250,14 @@ fun ImportScreen() { Spacer(modifier = Modifier.size(16.dp)) Button( onClick = { - authViewModel.importIdentity(secret, password) + accountViewModel.importIdentity(secret, password) }, modifier = Modifier .fillMaxWidth() .height(ButtonDefaults.MediumContainerHeight), - enabled = secret.isNotBlank() && !authState.isImporting, + enabled = secret.isNotBlank() && !accountState.isImporting, ) { - if (authState.isImporting) { + if (accountState.isImporting) { LoadingIndicator() } else { Text( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt index 505e806..81e7a99 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt @@ -21,16 +21,16 @@ import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import io.github.alexzhirkevich.qrose.rememberQrCodePainter import org.jetbrains.compose.resources.painterResource +import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState @Composable fun MyQrScreen() { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - val nostrViewModel = LocalNostrViewModel.current - val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() + val accountViewModel = LocalAccountViewModel.current + val currentUser by accountViewModel.currentUserProfile.collectAsStateWithLifecycle() Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt index 1986ded..cd0242d 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt @@ -55,6 +55,7 @@ import coop.composeapp.generated.resources.ic_scanner import kotlinx.coroutines.delay import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey +import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel @@ -72,9 +73,10 @@ fun NewChatScreen() { val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current val nostrViewModel = LocalNostrViewModel.current + val accountViewModel = LocalAccountViewModel.current val chatViewModel = LocalChatViewModel.current - val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle() + val contactList by accountViewModel.contactList.collectAsStateWithLifecycle() var query by remember { mutableStateOf("") } val createGroup = remember { mutableStateOf(false) } @@ -96,13 +98,13 @@ fun NewChatScreen() { selectedReceivers.add(pubkey) } } else if (query.contains("@")) { - nostrViewModel.searchByAddress(query) { pubkey -> + accountViewModel.searchByAddress(query) { pubkey -> if (pubkey != null) { selectedReceivers.add(pubkey) } } } else { - nostrViewModel.searchByNostr(query) { results -> + accountViewModel.searchByNostr(query) { results -> searchResults.clear() searchResults.addAll(results) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt index b54064e..276f9f9 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -1,14 +1,14 @@ package su.reya.coop.screens import androidx.compose.runtime.Composable -import su.reya.coop.LocalAuthViewModel +import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalNavigator import su.reya.coop.Screen import su.reya.coop.shared.ProfileEditor @Composable fun NewIdentityScreen() { - val authViewModel = LocalAuthViewModel.current + val accountViewModel = LocalAccountViewModel.current val navigator = LocalNavigator.current ProfileEditor( @@ -16,7 +16,7 @@ fun NewIdentityScreen() { buttonLabel = "Continue", onBack = { navigator.goBack() }, onConfirm = { name, bio, bytes, type -> - authViewModel.createIdentity(name, bio, bytes, type) + accountViewModel.createIdentity(name, bio, bytes, type) navigator.navigate(Screen.Home) } ) 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 e0dad00..d886f41 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -48,7 +48,7 @@ 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.LocalAccountViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen @@ -60,21 +60,21 @@ fun OnboardingScreen() { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current - val authViewModel = LocalAuthViewModel.current + val accountViewModel = LocalAccountViewModel.current - val authState by authViewModel.state.collectAsStateWithLifecycle() + val accountState by accountViewModel.state.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() // Navigate to Home on successful external signer connection - LaunchedEffect(authState.signerRequired) { - if (authState.signerRequired == false) { + LaunchedEffect(accountState.signerRequired) { + if (accountState.signerRequired == false) { navigator.navigate(Screen.Home) } } // Show connection errors - LaunchedEffect(authState.importError) { - authState.importError?.let { + LaunchedEffect(accountState.importError) { + accountState.importError?.let { snackbarHostState.showSnackbar(it) } } @@ -176,10 +176,10 @@ fun OnboardingScreen() { Spacer(modifier = Modifier.size(8.dp)) FilledTonalButton( onClick = { - if (authViewModel.isExternalSignerAvailable()) { + if (accountViewModel.isExternalSignerAvailable()) { // Connect to the external signer // TODO: show all available signers? - authViewModel.connectExternalSigner() + accountViewModel.connectExternalSigner() } else { scope.launch { val result = snackbarHostState.showSnackbar( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt index 27431e3..6689755 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt @@ -68,7 +68,7 @@ import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayUrl import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel -import su.reya.coop.LocalRelayViewModel +import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalSnackbarHostState @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -76,7 +76,7 @@ import su.reya.coop.LocalSnackbarHostState fun RelayScreen() { val navigator = LocalNavigator.current val nostrViewModel = LocalNostrViewModel.current - val relayViewModel = LocalRelayViewModel.current + val accountViewModel = LocalAccountViewModel.current val snackbarHostState = LocalSnackbarHostState.current val scope = rememberCoroutineScope() @@ -99,12 +99,12 @@ fun RelayScreen() { var relayToDelete by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - relayViewModel.loadCurrentUserRelayList() - relayViewModel.loadCurrentUserMsgRelayList() + accountViewModel.loadCurrentUserRelayList() + accountViewModel.loadCurrentUserMsgRelayList() } - val loadedRelayList by relayViewModel.currentUserRelayList.collectAsStateWithLifecycle() - val loadedMsgRelayList by relayViewModel.currentUserMsgRelayList.collectAsStateWithLifecycle() + val loadedRelayList by accountViewModel.currentUserRelayList.collectAsStateWithLifecycle() + val loadedMsgRelayList by accountViewModel.currentUserMsgRelayList.collectAsStateWithLifecycle() LaunchedEffect(loadedRelayList) { if (loadedRelayList.isNotEmpty()) { @@ -341,7 +341,7 @@ fun RelayScreen() { relayToDelete = null return@TextButton } - relayViewModel.removeMsgRelay(relayToDelete!!) + accountViewModel.removeMsgRelay(relayToDelete!!) msgRelayList.removeIf { it.toString() == relayToDelete } relayToDelete = null } @@ -365,7 +365,7 @@ fun AddRelayDialog( onMsgRelayAdded: (newRelay: String) -> Unit, onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit, ) { - val relayViewModel = LocalRelayViewModel.current + val accountViewModel = LocalAccountViewModel.current val snackbarHostState = LocalSnackbarHostState.current val scope = rememberCoroutineScope() @@ -416,17 +416,17 @@ fun AddRelayDialog( if (!isError) { when (selected) { "Messaging" -> { - relayViewModel.addMsgRelay(relayAddress) + accountViewModel.addMsgRelay(relayAddress) onMsgRelayAdded(relayAddress) } "Inbox" -> { - relayViewModel.addInboxRelay(relayAddress) + accountViewModel.addInboxRelay(relayAddress) onRelayAdded(relayAddress, RelayMetadata.WRITE) } "Outbox" -> { - relayViewModel.addOutboxRelay(relayAddress) + accountViewModel.addOutboxRelay(relayAddress) onRelayAdded(relayAddress, RelayMetadata.READ) } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt index c044f0e..1599437 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt @@ -3,16 +3,16 @@ package su.reya.coop.screens import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel import su.reya.coop.shared.ProfileEditor @Composable fun UpdateProfileScreen() { - val nostrViewModel = LocalNostrViewModel.current + val accountViewModel = LocalAccountViewModel.current val navigator = LocalNavigator.current - val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() + val currentUser by accountViewModel.currentUserProfile.collectAsStateWithLifecycle() val profile = currentUser?.metadata?.asRecord() ProfileEditor( @@ -23,7 +23,7 @@ fun UpdateProfileScreen() { initialPicture = profile?.picture, onBack = { navigator.goBack() }, onConfirm = { name, bio, bytes, type -> - nostrViewModel.updateProfile(name, bio, bytes, type) + accountViewModel.updateProfile(name, bio, bytes, type) navigator.goBack() } ) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt index f68f373..39ac829 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt @@ -31,6 +31,7 @@ import coop.composeapp.generated.resources.ic_check_circle import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey import rust.nostr.sdk.Timestamp +import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalNostrViewModel import su.reya.coop.Room import su.reya.coop.humanReadable @@ -44,6 +45,7 @@ fun ScreenerCard(room: Room) { val pubkey = room.members.firstOrNull() ?: return val nostrViewModel = LocalNostrViewModel.current + val accountViewModel = LocalAccountViewModel.current var isContact by remember { mutableStateOf(false) } var mutualContacts by remember { mutableStateOf>(emptySet()) } @@ -54,11 +56,11 @@ fun ScreenerCard(room: Room) { LaunchedEffect(pubkey) { // Check contact - nostrViewModel.verifyContact(pubkey) { isContact = it } + accountViewModel.verifyContact(pubkey) { isContact = it } // Get mutual contacts - nostrViewModel.mutualContacts(pubkey) { mutualContacts = it } + accountViewModel.mutualContacts(pubkey) { mutualContacts = it } // Get the last activity - nostrViewModel.verifyActivity(pubkey) { lastActivity = it } + accountViewModel.verifyActivity(pubkey) { lastActivity = it } } Column( 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 0f603ec..a8f951a 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 @@ -65,6 +65,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.UnsignedEvent +import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel @@ -92,7 +93,8 @@ fun ChatScreen( val listState = rememberLazyListState() // Get current user - val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() + val accountViewModel = LocalAccountViewModel.current + val currentUser by accountViewModel.currentUserProfile.collectAsStateWithLifecycle() // Get chat room by ID val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() 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 1174745..8960ac9 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt @@ -1,57 +1,27 @@ package su.reya.coop.viewmodel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull import rust.nostr.sdk.PublicKey -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 -data class NostrAppState( - val isBusy: Boolean = false, -) - -class NostrViewModel( - private val nostr: Nostr, - private val mediaRepository: MediaRepository, -) : BaseViewModel() { - private val _appState = MutableStateFlow(NostrAppState()) - val appState: StateFlow = _appState.asStateFlow() - - private val _contactList = MutableStateFlow>(emptySet()) - val contactList = _contactList.asStateFlow() - +class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { private val profilesMutex = Mutex() private val profiles = mutableMapOf>() private val metadataRequestChannel = Channel(Channel.UNLIMITED) private val seenPublicKeys = mutableSetOf() - @OptIn(ExperimentalCoroutinesApi::class) - val currentUserProfile = nostr.signer.publicKeyFlow - .flatMapLatest { pubkey -> - if (pubkey != null) getMetadata(pubkey) else flowOf(null) - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - init { // Launch continuous background observers viewModelScope.launch { runObserver() } @@ -60,9 +30,6 @@ class NostrViewModel( // Automatically reconnect bootstrap relays reconnect() - // Fetch metadata for the current user - fetchUserMetadata() - // Get all local stored metadata getCacheMetadata() } @@ -75,13 +42,6 @@ class NostrViewModel( } private suspend fun runObserver() = coroutineScope { - // Observe contact list updates - launch { - nostr.profiles.contactListUpdates.collect { contacts -> - _contactList.value = contacts.toSet() - } - } - // Observe metadata updates launch { nostr.profiles.metadataUpdates.collect { (pubkey, metadata) -> @@ -143,20 +103,7 @@ class NostrViewModel( } } } - - private fun fetchUserMetadata() { - viewModelScope.launch { - // Wait until the client is ready - nostr.waitUntilInitialized() - - // Wait until a signer is explicitly set (which updates publicKeyFlow) - val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first() - - // Get all metadata for the current user - nostr.profiles.getUserMetadata() - } - } - + private fun requestMetadata(pubkey: PublicKey) { if (seenPublicKeys.add(pubkey)) { metadataRequestChannel.trySend(pubkey) @@ -176,144 +123,4 @@ class NostrViewModel( return flow.asStateFlow() } - fun resetInternalState() { - _contactList.value = emptySet() - } - - fun updateProfile( - name: String? = null, - bio: String? = null, - picture: ByteArray? = null, - contentType: String? = null - ) { - 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 local state - _appState.update { it.copy(isBusy = false) } - } catch (e: Exception) { - showError("Error: ${e.message}") - _appState.update { it.copy(isBusy = false) } - } - } - } - - private fun newContact(publicKey: PublicKey) { - if (publicKey in contactList.value) return - - viewModelScope.launch { - try { - val updated = contactList.value + publicKey - // Publish new event - nostr.profiles.setContactList(updated.toList()) - // Optimistic local update - _contactList.update { it + publicKey } - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - } - - fun addContact(address: String) { - viewModelScope.launch { - val pubkey = try { - if (address.contains("@")) { - nostr.profiles.searchByAddress(address) - } else { - PublicKey.parse(address) - } - } catch (e: Exception) { - showError("Invalid contact address: ${e.message}") - return@launch - } - - newContact(pubkey) - } - } - - fun removeContact(publicKey: PublicKey) { - viewModelScope.launch { - if (publicKey !in contactList.value) return@launch - - try { - val updated = contactList.value - publicKey - // Publish new event - nostr.profiles.setContactList(updated.toList()) - // Optimistic local update - _contactList.update { it - publicKey } - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - } - - fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) { - viewModelScope.launch { - try { - onResult(nostr.profiles.searchByAddress(query)) - } catch (e: Exception) { - showError("Error: ${e.message}") - onResult(null) - } - } - } - - fun searchByNostr(query: String, onResult: (List) -> Unit) { - viewModelScope.launch { - try { - onResult(nostr.profiles.searchByNostr(query)) - } catch (e: Exception) { - showError("Error: ${e.message}") - onResult(emptyList()) - } - } - } - - fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) { - viewModelScope.launch { - try { - onResult(nostr.profiles.verifyActivity(pubkey)) - } catch (e: Exception) { - showError("Error: ${e.message}") - onResult(null) - } - } - } - - fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) { - viewModelScope.launch { - try { - onResult(nostr.profiles.verifyContact(pubkey)) - } catch (e: Exception) { - showError("Error: ${e.message}") - onResult(false) - } - } - } - - fun mutualContacts(pubkey: PublicKey, onResult: (Set) -> Unit) { - viewModelScope.launch { - try { - onResult(nostr.profiles.mutualContacts(pubkey)) - } catch (e: Exception) { - showError("Error: ${e.message}") - onResult(emptySet()) - } - } - } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountAuthDelegate.kt similarity index 81% rename from shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt rename to shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountAuthDelegate.kt index 967c327..a339fa1 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountAuthDelegate.kt @@ -1,9 +1,10 @@ -package su.reya.coop.viewmodel +package su.reya.coop.viewmodel.account -import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -23,46 +24,46 @@ import su.reya.coop.nostr.SignerPermissions import su.reya.coop.repository.MediaRepository import kotlin.time.Duration.Companion.seconds -data class AuthState( +data class AccountState( val signerRequired: Boolean? = null, val isNotificationBannerDismissed: Boolean = false, val isImporting: Boolean = false, val importError: String? = null, ) -class AuthViewModel( +class AccountAuthDelegate( private val nostr: Nostr, private val storage: AppStorage, private val mediaRepository: MediaRepository, private val externalSignerHandler: ExternalSignerHandler? = null, private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, -) : BaseViewModel() { + private val onError: (String) -> Unit, + private val onSignerReady: () -> Unit, + private val scope: CoroutineScope, +) { companion object { private const val KEY_USER_SIGNER = "user_signer" private const val KEY_APP_KEYS = "app_keys" private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed" } - private val _state = MutableStateFlow(AuthState()) - val state = _state.asStateFlow() + private val _state = MutableStateFlow(AccountState()) + val state: StateFlow = _state.asStateFlow() - init { - // Check if the notification banner has been dismissed + fun init() { checkNotificationBannerDismissedStatus() - - // Check local stored secret (secret key or bunker) login() } private fun checkNotificationBannerDismissedStatus() { - viewModelScope.launch { + scope.launch { val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true" _state.update { it.copy(isNotificationBannerDismissed = dismissed) } } } private fun login() { - viewModelScope.launch { + scope.launch { try { val secret = withTimeoutOrNull(5.seconds) { storage.getSecret(KEY_USER_SIGNER) @@ -78,39 +79,36 @@ class AuthViewModel( nostr.setSigner(signer) }.onSuccess { _state.update { it.copy(signerRequired = false) } + onSignerReady() }.onFailure { e -> - showError("Login failed: ${e.message}") + onError("Login failed: ${e.message}") _state.update { it.copy(signerRequired = true) } } } catch (e: Exception) { - showError("Login failed: ${e.message}") + onError("Login failed: ${e.message}") _state.update { it.copy(signerRequired = true) } } } } fun logout(onLogout: () -> Unit = {}) { - viewModelScope.launch { + scope.launch { try { - // Reset the nostr signer and prune the database nostr.signer.switch(Keys.generate()) nostr.prune() } catch (e: Exception) { - showError("Logout encountered an error: ${e.message}") + onError("Logout encountered an error: ${e.message}") } finally { - // Clear credentials from persistent storage storage.clear(KEY_USER_SIGNER) storage.clear(KEY_BANNER_DISMISSED) - // Call cleanup callback (e.g. to reset other ViewModels) onLogout() - // Reset local states _state.update { it.copy(signerRequired = true) } } } } fun dismissNotificationBanner() { - viewModelScope.launch { + scope.launch { storage.set(KEY_BANNER_DISMISSED, "true") _state.update { it.copy(isNotificationBannerDismissed = true) } } @@ -118,9 +116,7 @@ class AuthViewModel( private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) { val secret = storage.getSecret(KEY_APP_KEYS) - // If app keys are already stored, use them if (secret != null) return@withContext Keys.parse(secret) - // Generate new app keys and save to the secret storage val keys = Keys.generate() storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32()) keys @@ -136,9 +132,8 @@ class AuthViewModel( secret.startsWith("ncryptsec1") -> { if (password == null) throw IllegalArgumentException("Password is required") val enc = EncryptedSecretKey.fromBech32(secret) - val secret = enc.decrypt(password) - val keys = Keys(secret) - + val decrypted = enc.decrypt(password) + val keys = Keys(decrypted) keys to keys.secretKey().toBech32() } @@ -153,7 +148,6 @@ class AuthViewModel( val handler = externalSignerHandler ?: throw IllegalStateException("External signer not available on this platform") - // Format: nip55://packageName/hexPubkey val parts = secret.removePrefix("nip55://").split("/", limit = 2) val packageName = parts[0] val pubkey = PublicKey.parse(parts[1]) @@ -167,25 +161,23 @@ class AuthViewModel( } fun importIdentity(secret: String, password: String? = null) { - viewModelScope.launch { + scope.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) } + onSignerReady() } catch (e: Exception) { - showError("Import failed: ${e.message}") + onError("Import failed: ${e.message}") _state.update { it.copy(isImporting = false, importError = e.message) } } } } fun connectExternalSigner() { - viewModelScope.launch { + scope.launch { _state.update { it.copy(isImporting = true, importError = null) } try { val handler = externalSignerHandler @@ -209,17 +201,15 @@ class AuthViewModel( 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, isImporting = false) } + onSignerReady() } catch (e: Exception) { - showError("External signer connection failed: ${e.message}") + onError("External signer connection failed: ${e.message}") _state.update { it.copy(isImporting = false, importError = e.message) } } } @@ -235,7 +225,7 @@ class AuthViewModel( picture: ByteArray?, contentType: String? = null ) { - viewModelScope.launch { + scope.launch { _state.update { it.copy(isImporting = true, importError = null) } try { val keys = Keys.generate() @@ -243,19 +233,17 @@ class AuthViewModel( 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) } + onSignerReady() } catch (e: Exception) { - showError("Identity creation failed: ${e.message}") + onError("Identity creation failed: ${e.message}") _state.update { it.copy(isImporting = false, importError = e.message) } } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountContactDelegate.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountContactDelegate.kt new file mode 100644 index 0000000..26f16c4 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountContactDelegate.kt @@ -0,0 +1,137 @@ +package su.reya.coop.viewmodel.account + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.Timestamp +import su.reya.coop.nostr.Nostr + +class AccountContactDelegate( + private val nostr: Nostr, + private val scope: CoroutineScope, + private val onError: (String) -> Unit, +) { + private val _contactList = MutableStateFlow>(emptySet()) + val contactList: StateFlow> = _contactList.asStateFlow() + + fun init() { + observeContactList() + } + + fun reset() { + _contactList.value = emptySet() + } + + private fun observeContactList() { + scope.launch { + nostr.waitUntilInitialized() + nostr.profiles.contactListUpdates.collect { contacts -> + _contactList.value = contacts.toSet() + } + } + } + + private fun newContact(publicKey: PublicKey) { + if (publicKey in contactList.value) return + + scope.launch { + try { + val updated = contactList.value + publicKey + nostr.profiles.setContactList(updated.toList()) + _contactList.update { it + publicKey } + } catch (e: Exception) { + onError("Error: ${e.message}") + } + } + } + + fun addContact(address: String) { + scope.launch { + val pubkey = try { + if (address.contains("@")) { + nostr.profiles.searchByAddress(address) + } else { + PublicKey.parse(address) + } + } catch (e: Exception) { + onError("Invalid contact address: ${e.message}") + return@launch + } + + newContact(pubkey) + } + } + + fun removeContact(publicKey: PublicKey) { + scope.launch { + if (publicKey !in contactList.value) return@launch + + try { + val updated = contactList.value - publicKey + nostr.profiles.setContactList(updated.toList()) + _contactList.update { it - publicKey } + } catch (e: Exception) { + onError("Error: ${e.message}") + } + } + } + + fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) { + scope.launch { + try { + onResult(nostr.profiles.searchByAddress(query)) + } catch (e: Exception) { + onError("Error: ${e.message}") + onResult(null) + } + } + } + + fun searchByNostr(query: String, onResult: (List) -> Unit) { + scope.launch { + try { + onResult(nostr.profiles.searchByNostr(query)) + } catch (e: Exception) { + onError("Error: ${e.message}") + onResult(emptyList()) + } + } + } + + fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) { + scope.launch { + try { + onResult(nostr.profiles.verifyActivity(pubkey)) + } catch (e: Exception) { + onError("Error: ${e.message}") + onResult(null) + } + } + } + + fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) { + scope.launch { + try { + onResult(nostr.profiles.verifyContact(pubkey)) + } catch (e: Exception) { + onError("Error: ${e.message}") + onResult(false) + } + } + } + + fun mutualContacts(pubkey: PublicKey, onResult: (Set) -> Unit) { + scope.launch { + try { + onResult(nostr.profiles.mutualContacts(pubkey)) + } catch (e: Exception) { + onError("Error: ${e.message}") + onResult(emptySet()) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountProfileDelegate.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountProfileDelegate.kt new file mode 100644 index 0000000..0929760 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountProfileDelegate.kt @@ -0,0 +1,81 @@ +package su.reya.coop.viewmodel.account + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import rust.nostr.sdk.PublicKey +import su.reya.coop.Profile +import su.reya.coop.nostr.Nostr +import su.reya.coop.repository.MediaRepository + +class AccountProfileDelegate( + private val nostr: Nostr, + private val mediaRepository: MediaRepository, + private val onError: (String) -> Unit, + private val scope: CoroutineScope, +) { + private val _isUpdatingProfile = MutableStateFlow(false) + val isUpdatingProfile: StateFlow = _isUpdatingProfile.asStateFlow() + + @OptIn(ExperimentalCoroutinesApi::class) + val currentUserProfile: StateFlow = nostr.signer.publicKeyFlow + .flatMapLatest { pubkey -> + if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null) + } + .stateIn(scope, SharingStarted.WhileSubscribed(5000), null) + + @OptIn(ExperimentalCoroutinesApi::class) + private fun currentUserProfileFlow(pubkey: PublicKey) = merge( + flow { + nostr.waitUntilInitialized() + val cached = nostr.profiles.getAllCacheMetadata()[pubkey] + if (cached != null) emit(Profile(pubkey, cached)) + nostr.profiles.fetchMetadataBatch(listOf(pubkey)) + }, + nostr.profiles.metadataUpdates + .filter { (p, _) -> p == pubkey } + .map { (p, m) -> Profile(p, m) } + ) + + fun getUserMetadata() { + scope.launch { + nostr.profiles.getUserMetadata() + } + } + + fun updateProfile( + name: String? = null, + bio: String? = null, + picture: ByteArray? = null, + contentType: String? = null + ) { + scope.launch { + _isUpdatingProfile.value = true + try { + val avatarUrl = picture?.let { + mediaRepository.blossomUpload( + nostr.signer.get(), + it, + contentType ?: "image/jpeg" + ) + } + nostr.profiles.updateProfile(name, bio, avatarUrl) + _isUpdatingProfile.value = false + } catch (e: Exception) { + onError("Error: ${e.message}") + _isUpdatingProfile.value = false + } + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/RelayViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountRelayDelegate.kt similarity index 73% rename from shared/src/commonMain/kotlin/su/reya/coop/viewmodel/RelayViewModel.kt rename to shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountRelayDelegate.kt index f89e298..99621aa 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/RelayViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountRelayDelegate.kt @@ -1,9 +1,9 @@ -package su.reya.coop.viewmodel +package su.reya.coop.viewmodel.account -import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first @@ -13,11 +13,13 @@ import rust.nostr.sdk.RelayUrl import su.reya.coop.nostr.Nostr import kotlin.time.Duration.Companion.seconds -class RelayViewModel( +class AccountRelayDelegate( private val nostr: Nostr, -) : BaseViewModel() { + private val onError: (String) -> Unit, + private val scope: CoroutineScope, +) { private val _isRelayListEmpty = MutableStateFlow(false) - val isRelayListEmpty: StateFlow = _isRelayListEmpty.asStateFlow() + val isRelayListEmpty = _isRelayListEmpty.asStateFlow() private val _currentUserRelayList = MutableStateFlow>(emptyMap()) val currentUserRelayList = _currentUserRelayList.asStateFlow() @@ -25,22 +27,19 @@ class RelayViewModel( private val _currentUserMsgRelayList = MutableStateFlow>(emptyList()) val currentUserMsgRelayList = _currentUserMsgRelayList.asStateFlow() - init { - checkRelayList() + fun reset() { + _isRelayListEmpty.value = false } - private fun checkRelayList() { - viewModelScope.launch { - // Wait until the client is ready - nostr.waitUntilInitialized() - - // Wait until a signer is explicitly set (which updates publicKeyFlow) + @OptIn(ExperimentalCoroutinesApi::class) + fun checkRelayList() { + scope.launch { val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first() + println("user: ${currentUser.toBech32()}") - // Small delay to ensure all relays are connected - delay(2.seconds) + // Small delay to ensure subscription is ready + delay(6.seconds) - // Check if the relay list is empty val relays = nostr.relays.getMsgRelays(currentUser) if (relays.isEmpty()) _isRelayListEmpty.value = true } @@ -50,12 +49,8 @@ class RelayViewModel( _isRelayListEmpty.value = false } - fun resetInternalState() { - _isRelayListEmpty.value = false - } - fun refetchMsgRelays() { - viewModelScope.launch { + scope.launch { val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch val relays = nostr.relays.fetchMsgRelays(currentUser) @@ -64,23 +59,24 @@ class RelayViewModel( } fun useDefaultMsgRelayList() { - viewModelScope.launch { + scope.launch { try { val defaultRelays = nostr.relays.getDefaultMsgRelayList() nostr.relays.setMsgRelays(defaultRelays) } catch (e: Exception) { - showError("Error: ${e.message}") + onError("Error: ${e.message}") } } } fun loadCurrentUserRelayList() { - viewModelScope.launch { + scope.launch { try { - val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + val currentUser = + nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") _currentUserRelayList.value = nostr.relays.getRelayList(currentUser) } catch (e: Exception) { - showError("Error: ${e.message}") + onError("Error: ${e.message}") } } } @@ -90,13 +86,13 @@ class RelayViewModel( val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") return nostr.relays.getRelayList(currentUser) } catch (e: Exception) { - showError("Error: ${e.message}") + onError("Error: ${e.message}") return emptyMap() } } fun addInboxRelay(relay: String) { - viewModelScope.launch { + scope.launch { try { val relayUrl = RelayUrl.parse(relay) val relays = currentUserRelayListInternal().toMutableMap() @@ -104,13 +100,13 @@ class RelayViewModel( nostr.relays.setRelaylist(relays) } catch (e: Exception) { - showError("Error: ${e.message}") + onError("Error: ${e.message}") } } } fun addOutboxRelay(relay: String) { - viewModelScope.launch { + scope.launch { try { val relayUrl = RelayUrl.parse(relay) val relays = currentUserRelayListInternal().toMutableMap() @@ -118,13 +114,13 @@ class RelayViewModel( nostr.relays.setRelaylist(relays) } catch (e: Exception) { - showError("Error: ${e.message}") + onError("Error: ${e.message}") } } } fun removeRelay(relay: String) { - viewModelScope.launch { + scope.launch { try { val relayUrl = RelayUrl.parse(relay) val relays = currentUserRelayListInternal().toMutableMap() @@ -132,18 +128,19 @@ class RelayViewModel( nostr.relays.setRelaylist(relays) } catch (e: Exception) { - showError("Error: ${e.message}") + onError("Error: ${e.message}") } } } fun loadCurrentUserMsgRelayList() { - viewModelScope.launch { + scope.launch { try { - val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + val currentUser = + nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") _currentUserMsgRelayList.value = nostr.relays.getMsgRelays(currentUser) } catch (e: Exception) { - showError("Error: ${e.message}") + onError("Error: ${e.message}") } } } @@ -153,13 +150,13 @@ class RelayViewModel( val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") return nostr.relays.getMsgRelays(currentUser) } catch (e: Exception) { - showError("Error: ${e.message}") + onError("Error: ${e.message}") return emptyList() } } fun addMsgRelay(relay: String) { - viewModelScope.launch { + scope.launch { try { val relayUrl = RelayUrl.parse(relay) val relays = currentUserMsgRelayListInternal().toMutableSet() @@ -167,13 +164,13 @@ class RelayViewModel( nostr.relays.setMsgRelays(relays.toList()) } catch (e: Exception) { - showError("Error: ${e.message}") + onError("Error: ${e.message}") } } } fun removeMsgRelay(relay: String) { - viewModelScope.launch { + scope.launch { try { val relayUrl = RelayUrl.parse(relay) val relays = currentUserMsgRelayListInternal().toMutableSet() @@ -181,7 +178,7 @@ class RelayViewModel( nostr.relays.setMsgRelays(relays.toList()) } catch (e: Exception) { - showError("Error: ${e.message}") + onError("Error: ${e.message}") } } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountViewModel.kt new file mode 100644 index 0000000..fb5ed41 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountViewModel.kt @@ -0,0 +1,146 @@ +package su.reya.coop.viewmodel.account + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayMetadata +import rust.nostr.sdk.RelayUrl +import rust.nostr.sdk.Timestamp +import su.reya.coop.AppStorage +import su.reya.coop.Profile +import su.reya.coop.nostr.ExternalSignerHandler +import su.reya.coop.nostr.Nostr +import su.reya.coop.repository.MediaRepository +import su.reya.coop.viewmodel.BaseViewModel + +class AccountViewModel( + nostr: Nostr, + storage: AppStorage, + mediaRepository: MediaRepository, + externalSignerHandler: ExternalSignerHandler? = null, + defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, +) : BaseViewModel() { + private val relays = AccountRelayDelegate( + nostr = nostr, + onError = ::showError, + scope = viewModelScope, + ) + + private val auth = AccountAuthDelegate( + nostr = nostr, + storage = storage, + mediaRepository = mediaRepository, + externalSignerHandler = externalSignerHandler, + defaultDispatcher = defaultDispatcher, + onError = ::showError, + onSignerReady = { + profile.getUserMetadata() + relays.checkRelayList() + }, + scope = viewModelScope, + ) + + private val profile = AccountProfileDelegate( + nostr = nostr, + mediaRepository = mediaRepository, + onError = ::showError, + scope = viewModelScope, + ) + + private val contacts = AccountContactDelegate( + nostr = nostr, + onError = ::showError, + scope = viewModelScope, + ) + + + val state = auth.state + + val currentUserProfile: StateFlow = profile.currentUserProfile + + val isUpdatingProfile: StateFlow = profile.isUpdatingProfile + + val contactList: StateFlow> = contacts.contactList + + val isRelayListEmpty: StateFlow = relays.isRelayListEmpty + + val currentUserRelayList: StateFlow> = relays.currentUserRelayList + + val currentUserMsgRelayList: StateFlow> = relays.currentUserMsgRelayList + + init { + auth.init() + contacts.init() + } + + fun logout(onLogout: () -> Unit = {}) = auth.logout(onLogout) + + fun dismissNotificationBanner() = auth.dismissNotificationBanner() + + fun connectExternalSigner() = auth.connectExternalSigner() + + fun isExternalSignerAvailable(): Boolean = auth.isExternalSignerAvailable() + + fun importIdentity(secret: String, password: String? = null) = + auth.importIdentity(secret, password) + + fun createIdentity( + name: String, + bio: String? = null, + picture: ByteArray? = null, + contentType: String? = null, + ) = auth.createIdentity(name, bio, picture, contentType) + + fun updateProfile( + name: String? = null, + bio: String? = null, + picture: ByteArray? = null, + contentType: String? = null, + ) { + profile.updateProfile(name, bio, picture, contentType) + } + + fun resetInternalState() { + contacts.reset() + relays.reset() + } + + fun addContact(address: String) = contacts.addContact(address) + + fun removeContact(publicKey: PublicKey) = contacts.removeContact(publicKey) + + fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) = + contacts.searchByAddress(query, onResult) + + fun searchByNostr(query: String, onResult: (List) -> Unit) = + contacts.searchByNostr(query, onResult) + + fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) = + contacts.verifyActivity(pubkey, onResult) + + fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) = + contacts.verifyContact(pubkey, onResult) + + fun mutualContacts(pubkey: PublicKey, onResult: (Set) -> Unit) = + contacts.mutualContacts(pubkey, onResult) + + fun dismissRelayWarning() = relays.dismissRelayWarning() + + fun refetchMsgRelays() = relays.refetchMsgRelays() + + fun useDefaultMsgRelayList() = relays.useDefaultMsgRelayList() + + fun loadCurrentUserRelayList() = relays.loadCurrentUserRelayList() + + fun addInboxRelay(relay: String) = relays.addInboxRelay(relay) + + fun addOutboxRelay(relay: String) = relays.addOutboxRelay(relay) + + fun loadCurrentUserMsgRelayList() = relays.loadCurrentUserMsgRelayList() + + fun addMsgRelay(relay: String) = relays.addMsgRelay(relay) + + fun removeMsgRelay(relay: String) = relays.removeMsgRelay(relay) +} -- 2.49.1 From 467bc20013c092a885b0b48bb885029e924d111b Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 11 Jul 2026 17:44:18 +0700 Subject: [PATCH 9/9] update --- .../su/reya/coop/screens/NewIdentityScreen.kt | 4 +++ .../reya/coop/screens/UpdateProfileScreen.kt | 2 ++ .../su/reya/coop/shared/ProfileEditor.kt | 4 +-- .../viewmodel/account/AccountAuthDelegate.kt | 33 ++++++++++--------- 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt index 276f9f9..54d7d36 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -1,6 +1,8 @@ package su.reya.coop.screens import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalNavigator import su.reya.coop.Screen @@ -10,10 +12,12 @@ import su.reya.coop.shared.ProfileEditor fun NewIdentityScreen() { val accountViewModel = LocalAccountViewModel.current val navigator = LocalNavigator.current + val accountState by accountViewModel.state.collectAsStateWithLifecycle() ProfileEditor( title = "Create a new identity", buttonLabel = "Continue", + isBusy = accountState.isImporting, onBack = { navigator.goBack() }, onConfirm = { name, bio, bytes, type -> accountViewModel.createIdentity(name, bio, bytes, type) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt index 1599437..acdd26a 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt @@ -14,6 +14,7 @@ fun UpdateProfileScreen() { val currentUser by accountViewModel.currentUserProfile.collectAsStateWithLifecycle() val profile = currentUser?.metadata?.asRecord() + val isUpdatingProfile by accountViewModel.isUpdatingProfile.collectAsStateWithLifecycle() ProfileEditor( title = "Update profile", @@ -21,6 +22,7 @@ fun UpdateProfileScreen() { initialName = profile?.displayName ?: profile?.name ?: "", initialBio = profile?.about ?: "", initialPicture = profile?.picture, + isBusy = isUpdatingProfile, onBack = { navigator.goBack() }, onConfirm = { name, bio, bytes, type -> accountViewModel.updateProfile(name, bio, bytes, 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 f40e07a..92c8644 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/ProfileEditor.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/ProfileEditor.kt @@ -69,6 +69,7 @@ fun ProfileEditor( initialName: String = "", initialBio: String = "", initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL) + isBusy: Boolean = false, onBack: () -> Unit, onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit, ioDispatcher: CoroutineDispatcher = Dispatchers.IO, @@ -81,7 +82,6 @@ fun ProfileEditor( var name by remember(initialName) { mutableStateOf(initialName) } var bio by remember(initialBio) { mutableStateOf(initialBio) } var picture by remember(initialPicture) { mutableStateOf(initialPicture) } - var isBusy by remember { mutableStateOf(false) } val hasPicture = remember(picture) { when (picture) { @@ -269,7 +269,6 @@ fun ProfileEditor( .size(ButtonDefaults.MediumContainerHeight), onClick = { scope.launch { - isBusy = true try { val bytes = withContext(ioDispatcher) { (picture as? Uri)?.let { @@ -282,7 +281,6 @@ fun ProfileEditor( } catch (e: Exception) { snackbarHostState.showSnackbar(e.message ?: "Error") } - isBusy = false } }, enabled = name.isNotBlank() && !isBusy diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountAuthDelegate.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountAuthDelegate.kt index a339fa1..f6dc1d5 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountAuthDelegate.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountAuthDelegate.kt @@ -145,9 +145,7 @@ class AccountAuthDelegate( } secret.startsWith("nip55://") -> { - val handler = externalSignerHandler - ?: throw IllegalStateException("External signer not available on this platform") - + val handler = externalSignerHandler ?: throw IllegalStateException("Not available") val parts = secret.removePrefix("nip55://").split("/", limit = 2) val packageName = parts[0] val pubkey = PublicKey.parse(parts[1]) @@ -160,15 +158,21 @@ class AccountAuthDelegate( } } + fun isExternalSignerAvailable(): Boolean { + return externalSignerHandler?.isAvailable() == true + } + fun importIdentity(secret: String, password: String? = null) { scope.launch { _state.update { it.copy(isImporting = true, importError = null) } try { val (signer, decryptedSecret) = createSigner(secret, password) + nostr.setSigner(signer) + onSignerReady() + storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret) _state.update { it.copy(signerRequired = false, isImporting = false) } - onSignerReady() } catch (e: Exception) { onError("Import failed: ${e.message}") _state.update { it.copy(isImporting = false, importError = e.message) } @@ -180,8 +184,8 @@ class AccountAuthDelegate( scope.launch { _state.update { it.copy(isImporting = true, importError = null) } try { - val handler = externalSignerHandler - ?: throw IllegalStateException("Signer not available") + val handler = + externalSignerHandler ?: throw IllegalStateException("Signer not available") val permissions = SignerPermissions.toJson( listOf( @@ -200,14 +204,13 @@ class AccountAuthDelegate( val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected") val signer = ExternalSignerProxy(handler, result.pubkey) + val uri = "nip55://${result.packageName}/${result.pubkey.toHex()}" nostr.setSigner(signer) - storage.setSecret( - KEY_USER_SIGNER, - "nip55://${result.packageName}/${result.pubkey.toHex()}" - ) - _state.update { it.copy(signerRequired = false, isImporting = false) } onSignerReady() + + storage.setSecret(KEY_USER_SIGNER, uri) + _state.update { it.copy(signerRequired = false, isImporting = false) } } catch (e: Exception) { onError("External signer connection failed: ${e.message}") _state.update { it.copy(isImporting = false, importError = e.message) } @@ -215,10 +218,6 @@ class AccountAuthDelegate( } } - fun isExternalSignerAvailable(): Boolean { - return externalSignerHandler?.isAvailable() == true - } - fun createIdentity( name: String, bio: String?, @@ -233,15 +232,17 @@ class AccountAuthDelegate( val avatarUrl = picture?.let { mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg") } + nostr.profiles.createIdentity( keys = keys, name = name, bio = bio, picture = avatarUrl ) + onSignerReady() + storage.setSecret(KEY_USER_SIGNER, secret) _state.update { it.copy(signerRequired = false, isImporting = false) } - onSignerReady() } catch (e: Exception) { onError("Identity creation failed: ${e.message}") _state.update { it.copy(isImporting = false, importError = e.message) } -- 2.49.1