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) +}