From c5b76e9b2f3a33a342e1d1974e3f8760990f908e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 10 Jul 2026 20:31:15 +0700 Subject: [PATCH] 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}") + } + } + } +}