diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index d2b4f17..b4787cf 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -34,8 +34,7 @@ import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay -import su.reya.coop.repository.ErrorRepository -import su.reya.coop.screens.chat.ChatScreen +import kotlinx.coroutines.launch import su.reya.coop.screens.ContactListScreen import su.reya.coop.screens.HomeScreen import su.reya.coop.screens.ImportScreen @@ -48,9 +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.account.AccountViewModel val LocalNostrViewModel = staticCompositionLocalOf { error("No NostrViewModel provided") @@ -60,10 +60,11 @@ val LocalChatViewModel = staticCompositionLocalOf { error("No ChatViewModel provided") } -val LocalAuthViewModel = staticCompositionLocalOf { - error("No AuthViewModel provided") +val LocalAccountViewModel = staticCompositionLocalOf { + error("No AccountViewModel provided") } + val LocalSnackbarHostState = staticCompositionLocalOf { error("No SnackbarHostState provided") } @@ -81,7 +82,7 @@ val LocalScanResult = staticCompositionLocalOf { fun App( nostrViewModel: NostrViewModel, chatViewModel: ChatViewModel, - authViewModel: AuthViewModel, + accountViewModel: AccountViewModel, ) { val context = LocalContext.current val activity = context as? ComponentActivity @@ -90,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() } @@ -118,8 +119,20 @@ fun App( } LaunchedEffect(Unit) { - ErrorRepository.errors.collect { message -> - snackbarHostState.showSnackbar(message) + launch { + accountViewModel.errorEvents.collect { message -> + snackbarHostState.showSnackbar(message) + } + } + launch { + chatViewModel.errorEvents.collect { message -> + snackbarHostState.showSnackbar(message) + } + } + launch { + nostrViewModel.errorEvents.collect { message -> + snackbarHostState.showSnackbar(message) + } } } @@ -163,7 +176,7 @@ fun App( CompositionLocalProvider( LocalNostrViewModel provides nostrViewModel, 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/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 d56a596..3c73159 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -13,9 +13,10 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import su.reya.coop.nostr.NostrManager -import su.reya.coop.viewmodel.AuthViewModel +import su.reya.coop.repository.MediaRepository import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.NostrViewModel +import su.reya.coop.viewmodel.account.AccountViewModel import kotlin.system.exitProcess class MainActivity : ComponentActivity() { @@ -26,18 +27,19 @@ class MainActivity : ComponentActivity() { private val factory by lazy { object : ViewModelProvider.Factory { private val storage = AppStore(this@MainActivity) + private val mediaRepository = MediaRepository() private val nostrViewModel = NostrViewModel(NostrManager.instance) - private val chatViewModel = ChatViewModel(NostrManager.instance) + private val chatViewModel = ChatViewModel(NostrManager.instance, mediaRepository) private val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher) - private val authViewModel = - AuthViewModel(NostrManager.instance, storage, 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(ChatViewModel::class.java) -> chatViewModel - modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel + modelClass.isAssignableFrom(AccountViewModel::class.java) -> accountViewModel else -> throw IllegalArgumentException("Unknown ViewModel class") } as T } @@ -46,7 +48,7 @@ class MainActivity : ComponentActivity() { private val nostrViewModel: NostrViewModel 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 -> @@ -85,14 +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, chatViewModel = chatViewModel, - authViewModel = authViewModel, + accountViewModel = accountViewModel, ) } } 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 4f16cc4..e20f1df 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt @@ -33,7 +33,6 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -59,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 @@ -73,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) } @@ -202,7 +203,7 @@ fun ContactListScreen() { confirmButton = { TextButton( onClick = { - contactToDelete?.let { nostrViewModel.removeContact(it) } + contactToDelete?.let { accountViewModel.removeContact(it) } contactToDelete = null } ) { @@ -223,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) } @@ -266,10 +268,8 @@ fun AddContactDialog(onDismissRequest: () -> Unit) { }, actions = { IconButton(onClick = { - scope.launch { - val success = nostrViewModel.addContact(contact) - if (success) onDismissRequest() - } + accountViewModel.addContact(contact) + onDismissRequest() }) { Icon( painter = painterResource(Res.drawable.ic_check), @@ -328,7 +328,7 @@ fun ContactListItem( ) { val nostrViewModel = LocalNostrViewModel.current val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } - val profile by profileFlow.collectAsState(initial = null) + val profile by profileFlow.collectAsStateWithLifecycle(initialValue = null) SegmentedListItem( onClick = onClick, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index f2fbe89..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,7 +89,7 @@ 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 @@ -113,22 +113,22 @@ fun HomeScreen() { val clipboardManager = LocalClipboard.current val nostrViewModel = LocalNostrViewModel.current val chatViewModel = LocalChatViewModel.current - val authViewModel = LocalAuthViewModel.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 nostrViewModel.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) } @@ -155,7 +155,7 @@ fun HomeScreen() { onPauseOrDispose { } } - LaunchedEffect(Unit) { + LaunchedEffect(accountState.signerRequired) { chatViewModel.refreshChatRooms() } @@ -280,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") @@ -319,11 +319,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 +467,7 @@ fun HomeScreen() { // Show the relay setup dialog if the msg relay list is empty if (isRelayListEmpty) { ModalBottomSheet( - onDismissRequest = { nostrViewModel.dismissRelayWarning() }, + onDismissRequest = { accountViewModel.dismissRelayWarning() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surfaceContainer, ) { @@ -573,15 +571,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 + accountViewModel.refetchMsgRelays() + isBusy = false }, modifier = Modifier .weight(1f) @@ -595,10 +587,8 @@ fun HomeScreen() { Button( enabled = !isBusy, onClick = { - scope.launch { - nostrViewModel.useDefaultMsgRelayList() - sheetState.hide() - } + accountViewModel.useDefaultMsgRelayList() + scope.launch { sheetState.hide() } }, modifier = Modifier .weight(1f) @@ -746,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) }, @@ -778,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 967b712..9960a9f 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -44,14 +43,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_scanner -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.Keys import rust.nostr.sdk.NostrConnectUri -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 @@ -64,13 +63,13 @@ fun ImportScreen() { val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current val focusManager = LocalFocusManager.current - val authViewModel = LocalAuthViewModel.current - val scope = rememberCoroutineScope() + val accountViewModel = LocalAccountViewModel.current + + val accountState by accountViewModel.state.collectAsStateWithLifecycle() var secret by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } var requirePassword by remember { mutableStateOf(false) } - var loading by remember { mutableStateOf(false) } LaunchedEffect(qrScanResult.content) { qrScanResult.content?.let { result -> @@ -98,6 +97,20 @@ fun ImportScreen() { } } + // Navigate to Home on successful import (signerRequired becomes false) + LaunchedEffect(accountState.signerRequired) { + if (accountState.signerRequired == false) { + navigator.navigate(Screen.Home) + } + } + + // Show import errors via snackbar + LaunchedEffect(accountState.importError) { + accountState.importError?.let { + snackbarHostState.showSnackbar(it) + } + } + Scaffold( containerColor = MaterialTheme.colorScheme.surfaceContainer, snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -164,7 +177,7 @@ fun ImportScreen() { BasicTextField( value = secret, onValueChange = { secret = it }, - enabled = !loading, + enabled = !accountState.isImporting, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions( @@ -209,7 +222,7 @@ fun ImportScreen() { BasicTextField( value = password, onValueChange = { password = it }, - enabled = !loading && requirePassword, + enabled = !accountState.isImporting && requirePassword, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions( @@ -237,25 +250,14 @@ fun ImportScreen() { Spacer(modifier = Modifier.size(16.dp)) Button( onClick = { - scope.launch { - loading = true - try { - // Import the identity - authViewModel.importIdentity(secret, password) - // Navigate to the home screen - navigator.navigate(Screen.Home) - } catch (e: Exception) { - snackbarHostState.showSnackbar(e.message ?: "Error") - loading = false - } - } + accountViewModel.importIdentity(secret, password) }, modifier = Modifier .fillMaxWidth() .height(ButtonDefaults.MediumContainerHeight), - enabled = secret.isNotBlank() && !loading, + enabled = secret.isNotBlank() && !accountState.isImporting, ) { - if (loading) { + 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 2c2fb10..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,14 +98,16 @@ fun NewChatScreen() { selectedReceivers.add(pubkey) } } else if (query.contains("@")) { - val pubkey = nostrViewModel.searchByAddress(query) - if (pubkey != null) { - selectedReceivers.add(pubkey) + accountViewModel.searchByAddress(query) { pubkey -> + if (pubkey != null) { + selectedReceivers.add(pubkey) + } } } else { - val results = nostrViewModel.searchByNostr(query) - searchResults.clear() - searchResults.addAll(results) + accountViewModel.searchByNostr(query) { results -> + searchResults.clear() + searchResults.addAll(results) + } } query = "" 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..54d7d36 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -1,22 +1,26 @@ package su.reya.coop.screens import androidx.compose.runtime.Composable -import su.reya.coop.LocalAuthViewModel +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalNavigator import su.reya.coop.Screen import su.reya.coop.shared.ProfileEditor @Composable fun NewIdentityScreen() { - val authViewModel = LocalAuthViewModel.current + val accountViewModel = LocalAccountViewModel.current val navigator = LocalNavigator.current + val accountState by accountViewModel.state.collectAsStateWithLifecycle() ProfileEditor( title = "Create a new identity", buttonLabel = "Continue", + isBusy = accountState.isImporting, onBack = { navigator.goBack() }, onConfirm = { name, bio, bytes, type -> - 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 a042cd7..d886f41 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -24,6 +24,8 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -44,8 +46,9 @@ import androidx.core.net.toUri import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.coop import kotlinx.coroutines.launch +import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.painterResource -import su.reya.coop.LocalAuthViewModel +import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen @@ -57,9 +60,25 @@ fun OnboardingScreen() { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current - val authViewModel = LocalAuthViewModel.current + val accountViewModel = LocalAccountViewModel.current + + val accountState by accountViewModel.state.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() + // Navigate to Home on successful external signer connection + LaunchedEffect(accountState.signerRequired) { + if (accountState.signerRequired == false) { + navigator.navigate(Screen.Home) + } + } + + // Show connection errors + LaunchedEffect(accountState.importError) { + accountState.importError?.let { + snackbarHostState.showSnackbar(it) + } + } + val logoPainter = painterResource(Res.drawable.coop) val expressiveFont = getExpressiveFontFamily() @@ -157,18 +176,12 @@ fun OnboardingScreen() { Spacer(modifier = Modifier.size(8.dp)) FilledTonalButton( onClick = { - scope.launch { - if (authViewModel.isExternalSignerAvailable()) { - try { - // Connect to the external signer - // TODO: show all available signers? - authViewModel.connectExternalSigner() - // Navigate to the home screen - navigator.navigate(Screen.Home) - } catch (e: Exception) { - e.message?.let { snackbarHostState.showSnackbar(it) } - } - } else { + if (accountViewModel.isExternalSignerAvailable()) { + // Connect to the external signer + // TODO: show all available signers? + accountViewModel.connectExternalSigner() + } else { + scope.launch { val result = snackbarHostState.showSnackbar( message = "External signer not installed. Please install Amber or alternatives.", actionLabel = "Install", diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt index 37515e8..6689755 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.LocalAccountViewModel import su.reya.coop.LocalSnackbarHostState @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun RelayScreen() { val navigator = LocalNavigator.current - val snackbarHostState = LocalSnackbarHostState.current val nostrViewModel = LocalNostrViewModel.current + val accountViewModel = LocalAccountViewModel.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()) + accountViewModel.loadCurrentUserRelayList() + accountViewModel.loadCurrentUserMsgRelayList() + } + + val loadedRelayList by accountViewModel.currentUserRelayList.collectAsStateWithLifecycle() + val loadedMsgRelayList by accountViewModel.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 } + accountViewModel.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 accountViewModel = LocalAccountViewModel.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" -> { + accountViewModel.addMsgRelay(relayAddress) + onMsgRelayAdded(relayAddress) + } + + "Inbox" -> { + accountViewModel.addInboxRelay(relayAddress) + onRelayAdded(relayAddress, RelayMetadata.WRITE) + } + + "Outbox" -> { + accountViewModel.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/UpdateProfileScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt index c044f0e..acdd26a 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt @@ -3,17 +3,18 @@ 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() + val isUpdatingProfile by accountViewModel.isUpdatingProfile.collectAsStateWithLifecycle() ProfileEditor( title = "Update profile", @@ -21,9 +22,10 @@ fun UpdateProfileScreen() { initialName = profile?.displayName ?: profile?.name ?: "", initialBio = profile?.about ?: "", initialPicture = profile?.picture, + isBusy = isUpdatingProfile, 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 ede3bf9..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 @@ -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,10 +28,10 @@ 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 +import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalNostrViewModel import su.reya.coop.Room import su.reya.coop.humanReadable @@ -46,7 +45,7 @@ fun ScreenerCard(room: Room) { val pubkey = room.members.firstOrNull() ?: return val nostrViewModel = LocalNostrViewModel.current - val scope = rememberCoroutineScope() + val accountViewModel = LocalAccountViewModel.current var isContact by remember { mutableStateOf(false) } var mutualContacts by remember { mutableStateOf>(emptySet()) } @@ -56,14 +55,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 + accountViewModel.verifyContact(pubkey) { isContact = it } + // Get mutual contacts + accountViewModel.mutualContacts(pubkey) { mutualContacts = it } + // Get the last activity + 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 2468f46..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 @@ -59,11 +59,13 @@ 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 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 @@ -77,7 +79,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 @@ -87,7 +93,8 @@ fun ChatScreen(id: Long, screening: Boolean = false) { 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() @@ -120,20 +127,16 @@ fun ChatScreen(id: Long, screening: Boolean = false) { val sendFile = { uri: Uri -> scope.launch { - try { - // Read file - val file = withContext(Dispatchers.IO) { - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - } - - // Parse the file content type - val type = context.contentResolver.getType(uri) - - // Send message - chatViewModel.sendFileMessage(id, file, type) - } catch (e: Exception) { - snackbarHostState.showSnackbar("Error: ${e.message}") + // Read file on IO dispatcher + val file = withContext(coroutineDispatcher) { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } } + + // Parse the file content type + val type = context.contentResolver.getType(uri) + + // Send message (handles errors internally via ViewModel) + chatViewModel.sendFileMessage(id, file, type) } } @@ -153,12 +156,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 b3827fb..92c8644 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 @@ -68,8 +69,10 @@ fun ProfileEditor( initialName: String = "", initialBio: String = "", initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL) + isBusy: Boolean = false, onBack: () -> Unit, - onConfirm: suspend (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 @@ -79,7 +82,6 @@ fun ProfileEditor( var name by remember(initialName) { mutableStateOf(initialName) } var bio by remember(initialBio) { mutableStateOf(initialBio) } var picture by remember(initialPicture) { mutableStateOf(initialPicture) } - var isBusy by remember { mutableStateOf(false) } val hasPicture = remember(picture) { when (picture) { @@ -267,9 +269,8 @@ fun ProfileEditor( .size(ButtonDefaults.MediumContainerHeight), onClick = { scope.launch { - isBusy = true try { - val bytes = withContext(Dispatchers.IO) { + val bytes = withContext(ioDispatcher) { (picture as? Uri)?.let { context.contentResolver.openInputStream(it)?.readBytes() } @@ -280,7 +281,6 @@ fun ProfileEditor( } catch (e: Exception) { snackbarHostState.showSnackbar(e.message ?: "Error") } - isBusy = false } }, enabled = name.isNotBlank() && !isBusy diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index e61a691..d06109b 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -41,8 +41,7 @@ data class Room( } companion object { - fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room { - val id = rumor.roomId() + fun new(rumor: UnsignedEvent, userPubkey: PublicKey, id: Long = rumor.roomId()): Room { val createdAt = rumor.createdAt() val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content() diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt index dac7031..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}") } } @@ -170,36 +177,35 @@ class MessageManager(private val nostr: Nostr) { // Get all DM events val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm")) - val events = client?.database()?.query(filter) + val events = client?.database()?.query(filter)?.toVec() ?: return null // Collect rooms val roomsMap: MutableMap = mutableMapOf() events - ?.toVec() - ?.map { UnsignedEvent.fromJson(it.content()) } - ?.filter { it.tags().publicKeys().isNotEmpty() } - ?.forEach { event -> - val newRoom = Room.new(rumor = event, userPubkey = userPubkey) - val existingRoom = roomsMap[newRoom.id] + .map { UnsignedEvent.fromJson(it.content()) } + .filter { it.tags().publicKeys().isNotEmpty() } + .forEach { rumor -> + val id = rumor.roomId() + val isFromMe = rumor.author() == userPubkey + val existing = roomsMap[id] + val createdAt = rumor.createdAt() - // Check if the room already exists - if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) { - val rTag = SingleLetterTag.lowercase(Alphabet.R) - val filter = Filter().kind(kind).pubkey(userPubkey) - .customTag(rTag, newRoom.id.toString()) - - // Determine if it's an ongoing room - val isOngoing = - client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false - - // Append room to map - roomsMap[newRoom.id] = - if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom + // If the room is new or the current rumor is newer than the existing one + if (existing == null || createdAt.asSecs() > existing.createdAt.asSecs()) { + // A room is "Ongoing" if it was already marked as such or if the current rumor is from the user + val isOngoing = (existing?.kind == RoomKind.Ongoing) || isFromMe + val room = Room.new(rumor = rumor, userPubkey = userPubkey, id = id) + roomsMap[id] = if (isOngoing) room.copy(kind = RoomKind.Ongoing) else room + } else if (isFromMe && existing.kind != RoomKind.Ongoing) { + // If it's an older rumor but sent by the user, mark the room as Ongoing + roomsMap[id] = existing.copy(kind = RoomKind.Ongoing) } } 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 330fd31..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,12 +1,15 @@ package su.reya.coop.nostr +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import rust.nostr.sdk.AsyncNostrSigner @@ -30,6 +33,7 @@ import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.initLogger import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds object NostrManager { val instance = Nostr() @@ -47,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()) @@ -59,17 +65,18 @@ class Nostr { private val isInitialized = MutableStateFlow(false) - private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) + private val _newEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 100, + onBufferOverflow = BufferOverflow.SUSPEND + ) val newEvents = _newEvents.asSharedFlow() - suspend fun emitNewEvent(event: UnsignedEvent) { - _newEvents.emit(event) + fun emitNewEvent(event: UnsignedEvent) { + _newEvents.tryEmit(event) } - suspend fun init( - dbPath: String, - logLevel: LogLevel = LogLevel.WARN - ) { + suspend fun init(dbPath: String, logLevel: LogLevel = LogLevel.WARN) { try { if (isInitialized.value) return @@ -105,7 +112,8 @@ class Nostr { } suspend fun waitUntilInitialized() { - isInitialized.first { it } + withTimeoutOrNull(30.seconds) { isInitialized.first { it } } + ?: throw IllegalStateException("Nostr initialization timed out") } suspend fun connectBootstrapRelays() { @@ -159,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/nostr/ProfileManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt index c276df9..7579420 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt @@ -4,6 +4,7 @@ import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.statement.HttpResponse +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import rust.nostr.sdk.AckPolicy @@ -33,10 +34,19 @@ class ProfileManager(private val nostr: Nostr) { private val signer: UniversalSigner get() = nostr.signer private val _metadataUpdates = - MutableSharedFlow>(extraBufferCapacity = 100) + MutableSharedFlow>( + replay = 0, + extraBufferCapacity = 100, + onBufferOverflow = BufferOverflow.SUSPEND + ) val metadataUpdates = _metadataUpdates.asSharedFlow() - private val _contactListUpdates = MutableSharedFlow>(extraBufferCapacity = 100) + private val _contactListUpdates = + MutableSharedFlow>( + replay = 0, + extraBufferCapacity = 100, + onBufferOverflow = BufferOverflow.SUSPEND + ) val contactListUpdates = _contactListUpdates.asSharedFlow() suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) { @@ -138,19 +148,24 @@ class ProfileManager(private val nostr: Nostr) { bio: String? = null, picture: String? = null ): Metadata { - val currentUser = - signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") - try { - val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord() - val newRecord = record.copy( - displayName = name ?: record.displayName, - about = bio ?: record.about, - picture = picture ?: record.picture - ) - val newMetadata = Metadata.fromRecord(newRecord) - val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer) + val currentUser = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + // Get the latest metadata event + val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord() + + // Build a new metadata based on old records + val newMetadata = Metadata.fromRecord( + record.copy( + displayName = name ?: record.displayName, + about = bio ?: record.about, + picture = picture ?: record.picture + ) + ) + + // Send the new metadata event + val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer) client?.sendEvent( event = event, target = SendEventTarget.broadcast(), diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt index d763018..6c7d57f 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt @@ -104,6 +104,32 @@ class RelayManager(private val nostr: Nostr) { return msgRelayList } + suspend fun getRelayList(publicKey: PublicKey): Map { + try { + val kind = Kind.fromStd(KindStandard.RELAY_LIST) + val filter = Filter().kind(kind).author(publicKey).limit(1u) + val events = client?.database()?.query(filter) + + return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap()) + } catch (e: Exception) { + throw IllegalStateException("Failed to get relay list: ${e.message}", e) + } + } + + suspend fun setRelaylist(relays: Map) { + try { + val event = EventBuilder.relayList(relays).finalizeAsync(signer) + + client?.sendEvent( + event = event, + target = SendEventTarget.broadcast(), + ackPolicy = AckPolicy.none(), + ) + } catch (e: Exception) { + throw IllegalStateException("Failed to set msg relays: ${e.message}", e) + } + } + suspend fun setMsgRelays(urls: List) { try { val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer) @@ -154,30 +180,4 @@ class RelayManager(private val nostr: Nostr) { throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e) } } - - suspend fun getRelayList(publicKey: PublicKey): Map { - try { - val kind = Kind.fromStd(KindStandard.RELAY_LIST) - val filter = Filter().kind(kind).author(publicKey).limit(1u) - val events = client?.database()?.query(filter) - - return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap()) - } catch (e: Exception) { - throw IllegalStateException("Failed to get relay list: ${e.message}", e) - } - } - - suspend fun setRelaylist(relays: Map) { - try { - val event = EventBuilder.relayList(relays).finalizeAsync(signer) - - client?.sendEvent( - event = event, - target = SendEventTarget.broadcast(), - ackPolicy = AckPolicy.none(), - ) - } catch (e: Exception) { - throw IllegalStateException("Failed to set msg relays: ${e.message}", e) - } - } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt index 4872ac7..c3119fe 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt @@ -29,16 +29,14 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner { /** * Switch to a new signer. */ - suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock { - val pubkey = try { - withTimeoutOrNull(20.seconds) { - newSigner.getPublicKeyAsync() - } - } catch (e: Exception) { - throw IllegalStateException("Failed to get public key from signer", e) + suspend fun switch(newSigner: AsyncNostrSigner) { + val pubkey = withTimeoutOrNull(20.seconds) { newSigner.getPublicKeyAsync() } + ?: throw IllegalStateException("Failed to get public key from signer") + + mutex.withLock { + signer = newSigner + _publicKeyFlow.value = pubkey } - signer = newSigner - _publicKeyFlow.value = pubkey } override suspend fun getPublicKeyAsync(): PublicKey? { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/repository/ErrorRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/ErrorRepository.kt deleted file mode 100644 index 8116902..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/repository/ErrorRepository.kt +++ /dev/null @@ -1,13 +0,0 @@ -package su.reya.coop.repository - -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.receiveAsFlow - -object ErrorRepository { - private val _errors = Channel(Channel.BUFFERED) - val errors = _errors.receiveAsFlow() - - fun showError(message: String) { - _errors.trySend(message) - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/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 deleted file mode 100644 index 0492606..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt +++ /dev/null @@ -1,225 +0,0 @@ -package su.reya.coop.viewmodel - -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull -import rust.nostr.sdk.AsyncNostrSigner -import rust.nostr.sdk.EncryptedSecretKey -import rust.nostr.sdk.Keys -import rust.nostr.sdk.NostrConnect -import rust.nostr.sdk.NostrConnectUri -import rust.nostr.sdk.PublicKey -import su.reya.coop.AppStorage -import su.reya.coop.nostr.ExternalSignerHandler -import su.reya.coop.nostr.ExternalSignerProxy -import su.reya.coop.nostr.Nostr -import su.reya.coop.nostr.SignerPermissions -import su.reya.coop.repository.MediaRepository -import kotlin.time.Duration.Companion.seconds - -data class AuthState( - val signerRequired: Boolean? = null, - val isNotificationBannerDismissed: Boolean = false, -) - -class AuthViewModel( - private val nostr: Nostr, - private val storage: AppStorage, - private val externalSignerHandler: ExternalSignerHandler? = null, -) : BaseViewModel() { - private val mediaRepository = MediaRepository() - - companion object { - private const val KEY_USER_SIGNER = "user_signer" - private const val KEY_APP_KEYS = "app_keys" - private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed" - } - - private val _state = MutableStateFlow(AuthState()) - val state = _state.asStateFlow() - - init { - // Check if the notification banner has been dismissed - checkNotificationBannerDismissedStatus() - - // Check local stored secret (secret key or bunker) - login() - } - - private fun checkNotificationBannerDismissedStatus() { - viewModelScope.launch { - val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true" - _state.update { it.copy(isNotificationBannerDismissed = dismissed) } - } - } - - private fun login() { - viewModelScope.launch { - try { - val secret = withTimeoutOrNull(5.seconds) { - storage.getSecret(KEY_USER_SIGNER) - } - - if (secret == null) { - _state.update { it.copy(signerRequired = true) } - return@launch - } - - runCatching { - val (signer, _) = createSigner(secret) - nostr.setSigner(signer) - }.onSuccess { - _state.update { it.copy(signerRequired = false) } - }.onFailure { e -> - showError("Login failed: ${e.message}") - _state.update { it.copy(signerRequired = true) } - } - } catch (e: Exception) { - showError("Login failed: ${e.message}") - _state.update { it.copy(signerRequired = true) } - } - } - } - - fun logout(onLogout: () -> Unit = {}) { - viewModelScope.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}") - } 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 { - storage.set(KEY_BANNER_DISMISSED, "true") - _state.update { it.copy(isNotificationBannerDismissed = true) } - } - } - - private suspend fun getOrInitAppKeys(): Keys { - val secret = storage.getSecret(KEY_APP_KEYS) - // If app keys are already stored, use them - if (secret != null) return 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 - } - - private suspend fun createSigner( - secret: String, - password: String? = null - ): Pair { - return when { - secret.startsWith("nsec1") -> Keys.parse(secret) to null - - 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) - - keys to keys.secretKey().toBech32() - } - - secret.startsWith("bunker://") -> { - val appKeys = getOrInitAppKeys() - val bunker = NostrConnectUri.parse(secret) - val timeout = 50.seconds - NostrConnect(uri = bunker, appKeys, timeout, null) to null - } - - secret.startsWith("nip55://") -> { - 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]) - - handler.setPackageName(packageName) - ExternalSignerProxy(handler, pubkey) to null - } - - else -> throw IllegalArgumentException("Invalid secret format") - } - } - - suspend fun importIdentity(secret: String, password: String? = null) { - val (signer, decryptedSecret) = createSigner(secret, password) - // Update signer - nostr.setSigner(signer) - // Persist the secret in the secret storage - storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret) - // Update local states - _state.update { it.copy(signerRequired = false) } - } - - suspend fun connectExternalSigner() { - val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") - - val permissions = SignerPermissions.toJson( - listOf( - SignerPermissions.signEvent(0), - SignerPermissions.signEvent(3), - SignerPermissions.signEvent(10000), - SignerPermissions.signEvent(10050), - SignerPermissions.signEvent(10063), - SignerPermissions.signEvent(22242), - SignerPermissions.signEvent(30030), - SignerPermissions.signEvent(30315), - SignerPermissions.nip44Encrypt(), - SignerPermissions.nip44Decrypt(), - ) - ) - - val 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) } - } - - fun isExternalSignerAvailable(): Boolean { - return externalSignerHandler?.isAvailable() == true - } - - suspend fun createIdentity( - name: String, - bio: String?, - picture: ByteArray?, - contentType: String? = null - ) { - val keys = Keys.generate() - val secret = keys.secretKey().toBech32() - val avatarUrl = picture?.let { - mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg") - } - // Create identity - nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl) - // Persist the secret in the secret storage - storage.setSecret(KEY_USER_SIGNER, secret) - // Update local states - _state.update { it.copy(signerRequired = false) } - } -} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt index e1b0c13..e4d17d4 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt @@ -1,10 +1,19 @@ package su.reya.coop.viewmodel import androidx.lifecycle.ViewModel -import su.reya.coop.repository.ErrorRepository +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow abstract class BaseViewModel : ViewModel() { + private val _errorEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.SUSPEND + ) + val errorEvents = _errorEvents.asSharedFlow() + protected fun showError(message: String) { - ErrorRepository.showError(message) + _errorEvents.tryEmit(message) } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt index baee6b6..8ea4735 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt @@ -1,11 +1,12 @@ package su.reya.coop.viewmodel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -15,7 +16,6 @@ import rust.nostr.sdk.EventId import rust.nostr.sdk.Kind import rust.nostr.sdk.KindStandard import rust.nostr.sdk.PublicKey -import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.Tag import rust.nostr.sdk.UnsignedEvent import su.reya.coop.Room @@ -24,34 +24,32 @@ import su.reya.coop.repository.MediaRepository import su.reya.coop.roomId data class ChatState( - val rooms: Set = emptySet(), - val isSyncing: Boolean = false, + val rooms: Map = emptyMap(), val isPartialProcessedGiftWrap: Boolean = false, ) -class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { - private val mediaRepository = MediaRepository() - +class ChatViewModel( + private val nostr: Nostr, + private val mediaRepository: MediaRepository, +) : BaseViewModel() { private val _state = MutableStateFlow(ChatState()) - val state = combine( - _state, - nostr.messages.messageSyncState - ) { local, state -> local.copy(isSyncing = state.isSyncing) }.stateIn( + val state = _state.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), ChatState() ) - private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) + private val _newEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 100, + onBufferOverflow = BufferOverflow.SUSPEND + ) val newEvents = _newEvents.asSharedFlow() - private val _sentReports = MutableSharedFlow>>() - val sentReport = _sentReports.asSharedFlow() + val chatRooms = state.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - val chatRooms = state.map { it.rooms } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet()) - - val isSyncing = state.map { it.isSyncing } + val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap } @@ -69,8 +67,8 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { _state.update { it.copy(isPartialProcessedGiftWrap = true) } } - // Refresh UI every 10 messages OR when sync is fully done - if (syncState.processedCount % 10 == 0 || !syncState.isSyncing) { + // Refresh UI every 100 messages OR when sync is fully done + if (syncState.processedCount % 100 == 0 || !syncState.isSyncing) { refreshChatRooms() } } @@ -80,26 +78,19 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { launch { nostr.newEvents.collect { event -> val roomId = event.roomId() - val existingRoom = _state.value.rooms.firstOrNull { it.id == roomId } + val existingRoom = _state.value.rooms[roomId] if (existingRoom == null) { val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect val newRoom = Room.new(event, currentUser) - _state.update { - it.copy( - rooms = (it.rooms + newRoom).sortedDescending().toSet() - ) - } + _state.update { it.copy(rooms = it.rooms + (newRoom.id to newRoom)) } } else { updateRoomList(roomId, event) } - _newEvents.emit(event) + _newEvents.tryEmit(event) } } - - // Initial load of rooms - refreshChatRooms() } } @@ -120,7 +111,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { // Check if the room already exists val id = rumor.roomId() - val existingRoom = _state.value.rooms.firstOrNull { it.id == id } + val existingRoom = _state.value.rooms[id] // If the room already exists, return its ID if (existingRoom != null) { @@ -131,7 +122,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { val room = Room.new(rumor, currentUser) // Update the chat rooms state - _state.update { it.copy(rooms = (it.rooms + room).sortedDescending().toSet()) } + _state.update { it.copy(rooms = it.rooms + (room.id to room)) } return room.id } catch (e: Exception) { @@ -140,34 +131,36 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { } fun getChatRoom(id: Long): Room? { - return _state.value.rooms.firstOrNull { it.id == id } + return _state.value.rooms[id] } - suspend fun refreshChatRooms() { - try { - val rooms = nostr.messages.getChatRooms() ?: emptySet() - _state.update { currentState -> - val merged = currentState.rooms.associateBy { it.id }.toMutableMap() - // Add or update rooms from the database - rooms.forEach { room -> - merged[room.id] = room + 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) } - // Return as a sorted set to maintain UI consistency - currentState.copy(rooms = merged.values.sortedDescending().toSet()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + showError("Error: ${e.message}") } - } catch (e: Exception) { - showError("Error: ${e.message}") } } - suspend fun getChatRoomMessages(roomId: Long): List { - try { - return nostr.messages.getChatRoomMessages(roomId) - } 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) { @@ -186,6 +179,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { fun sendMessage(roomId: Long, message: String, replies: List = emptyList()) { if (message.isEmpty()) { showError("Message cannot be empty") + return } viewModelScope.launch { try { @@ -197,7 +191,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { replies = replies, onRumorCreated = { event -> updateRoomList(roomId, event) - viewModelScope.launch { _newEvents.emit(event) } + viewModelScope.launch { _newEvents.tryEmit(event) } }, ) } catch (e: Exception) { @@ -206,7 +200,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { } } - suspend fun sendFileMessage( + fun sendFileMessage( roomId: Long, file: ByteArray?, contentType: String? = "image/jpeg", @@ -214,11 +208,13 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { ) { if (file == null) return - try { - val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType) - if (uri != null) sendMessage(roomId, uri, replies) - } catch (e: Exception) { - throw IllegalArgumentException("Error: ${e.message}") + viewModelScope.launch { + try { + val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType) + if (uri != null) sendMessage(roomId, uri, replies) + } catch (e: Exception) { + showError("File upload failed: ${e.message}") + } } } @@ -235,24 +231,19 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) { _state.update { currentState -> - val updatedRooms = currentState.rooms.map { room -> - if (room.id == roomId) { - room.copy( - lastMessage = newMessage.content(), - createdAt = newMessage.createdAt() - ) - } else { - room - } - }.sortedDescending().toSet() - currentState.copy(rooms = updatedRooms) + val room = currentState.rooms[roomId] ?: return@update currentState + val updatedRoom = room.copy( + lastMessage = newMessage.content(), + createdAt = newMessage.createdAt() + ) + currentState.copy(rooms = currentState.rooms + (roomId to updatedRoom)) } } fun resetInternalState() { _state.update { it.copy( - rooms = emptySet(), + rooms = emptyMap(), isPartialProcessedGiftWrap = false, ) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt index 4c02d66..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,94 +1,35 @@ package su.reya.coop.viewmodel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -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.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(private val nostr: Nostr) : BaseViewModel() { - private val mediaRepository = MediaRepository() - - private val alwaysRunTasks = flow { - coroutineScope { - val observerJob = launch { runObserver() } - val batchingJob = launch { runMetadataBatching() } - try { - emit(Unit) - awaitCancellation() - } finally { - observerJob.cancel() - batchingJob.cancel() - } - } - } - - private val _appState = MutableStateFlow(NostrAppState()) - val appState: StateFlow = - combine(_appState, alwaysRunTasks) { state, _ -> state }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = NostrAppState() - ) - - private val _contactList = MutableStateFlow>(emptySet()) - val contactList = _contactList.asStateFlow() - private val profilesMutex = Mutex() private val profiles = mutableMapOf>() private val metadataRequestChannel = Channel(Channel.UNLIMITED) private val seenPublicKeys = mutableSetOf() - - val isRelayListEmpty = appState.map { it.isRelayListEmpty } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - - @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() } + viewModelScope.launch { runMetadataBatching() } + // Automatically reconnect bootstrap relays reconnect() - // Observe the signer state and verify the relay list - observeSignerAndCheckRelays() - // Get all local stored metadata getCacheMetadata() } @@ -101,13 +42,6 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { } 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) -> @@ -116,7 +50,7 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { } } - private suspend fun runMetadataBatching() = coroutineScope { + private suspend fun runMetadataBatching() { // Wait until the client is ready nostr.waitUntilInitialized() @@ -158,49 +92,27 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { viewModelScope.launch { // Wait until the client is ready nostr.waitUntilInitialized() + val cache = nostr.profiles.getAllCacheMetadata() - nostr.profiles.getAllCacheMetadata().forEach { (pubkey, metadata) -> - // Update the metadata state - updateMetadata(pubkey, Profile(pubkey, metadata)) - // Update seenPublicKeys to avoid duplicate requests - seenPublicKeys.add(pubkey) + profilesMutex.withLock { + cache.forEach { (pubkey, metadata) -> + val profile = Profile(pubkey, metadata) + profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile + seenPublicKeys.add(pubkey) + } } } } - - private fun observeSignerAndCheckRelays() { - 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() - - // 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) } - } - } - + private fun requestMetadata(pubkey: PublicKey) { if (seenPublicKeys.add(pubkey)) { - viewModelScope.launch { - metadataRequestChannel.send(pubkey) - } + metadataRequestChannel.trySend(pubkey) } } - private fun updateMetadata(pubkey: PublicKey, profile: Profile) { - viewModelScope.launch { - profilesMutex.withLock { - profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile - } + private suspend fun updateMetadata(pubkey: PublicKey, profile: Profile) { + profilesMutex.withLock { + profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile } } @@ -211,234 +123,4 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { return flow.asStateFlow() } - fun resetInternalState() { - _contactList.value = emptySet() - _appState.update { - it.copy( - isRelayListEmpty = false, - ) - } - } - - fun dismissRelayWarning() { - _appState.update { it.copy(isRelayListEmpty = false) } - } - - suspend fun updateProfile( - name: String? = null, - bio: String? = null, - picture: ByteArray? = null, - contentType: String? = null - ) { - _appState.update { it.copy(isBusy = true) } - try { - val avatarUrl = - picture?.let { - mediaRepository.blossomUpload( - nostr.signer.get(), - it, - contentType ?: "image/jpeg" - ) - } - val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl) - val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - - // 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}") - } - } - - 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) { - 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}") - } - } - - suspend fun addContact(address: String): Boolean { - val pubkey = try { - if (address.contains("@")) { - nostr.profiles.searchByAddress(address) - } else { - PublicKey.parse(address) - } - } catch (e: Exception) { - showError("Invalid contact address: ${e.message}") - return false - } - - return run { - newContact(pubkey) - true - } - } - - 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}") - } - } - } - - 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 - } - } - - suspend fun verifyContact(pubkey: PublicKey): Boolean { - return try { - nostr.profiles.verifyContact(pubkey) - } catch (e: Exception) { - showError("Error: ${e.message}") - false - } - } - - suspend fun mutualContacts(pubkey: PublicKey): Set { - return try { - nostr.profiles.mutualContacts(pubkey) - } catch (e: Exception) { - showError("Error: ${e.message}") - setOf() - } - } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountAuthDelegate.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountAuthDelegate.kt new file mode 100644 index 0000000..f6dc1d5 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountAuthDelegate.kt @@ -0,0 +1,252 @@ +package su.reya.coop.viewmodel.account + +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 +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import rust.nostr.sdk.AsyncNostrSigner +import rust.nostr.sdk.EncryptedSecretKey +import rust.nostr.sdk.Keys +import rust.nostr.sdk.NostrConnect +import rust.nostr.sdk.NostrConnectUri +import rust.nostr.sdk.PublicKey +import su.reya.coop.AppStorage +import su.reya.coop.nostr.ExternalSignerHandler +import su.reya.coop.nostr.ExternalSignerProxy +import su.reya.coop.nostr.Nostr +import su.reya.coop.nostr.SignerPermissions +import su.reya.coop.repository.MediaRepository +import kotlin.time.Duration.Companion.seconds + +data class AccountState( + val signerRequired: Boolean? = null, + val isNotificationBannerDismissed: Boolean = false, + val isImporting: Boolean = false, + val importError: String? = null, +) + +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, + 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(AccountState()) + val state: StateFlow = _state.asStateFlow() + + fun init() { + checkNotificationBannerDismissedStatus() + login() + } + + private fun checkNotificationBannerDismissedStatus() { + scope.launch { + val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true" + _state.update { it.copy(isNotificationBannerDismissed = dismissed) } + } + } + + private fun login() { + scope.launch { + try { + val secret = withTimeoutOrNull(5.seconds) { + storage.getSecret(KEY_USER_SIGNER) + } + + if (secret == null) { + _state.update { it.copy(signerRequired = true) } + return@launch + } + + runCatching { + val (signer, _) = createSigner(secret) + nostr.setSigner(signer) + }.onSuccess { + _state.update { it.copy(signerRequired = false) } + onSignerReady() + }.onFailure { e -> + onError("Login failed: ${e.message}") + _state.update { it.copy(signerRequired = true) } + } + } catch (e: Exception) { + onError("Login failed: ${e.message}") + _state.update { it.copy(signerRequired = true) } + } + } + } + + fun logout(onLogout: () -> Unit = {}) { + scope.launch { + try { + nostr.signer.switch(Keys.generate()) + nostr.prune() + } catch (e: Exception) { + onError("Logout encountered an error: ${e.message}") + } finally { + storage.clear(KEY_USER_SIGNER) + storage.clear(KEY_BANNER_DISMISSED) + onLogout() + _state.update { it.copy(signerRequired = true) } + } + } + } + + fun dismissNotificationBanner() { + scope.launch { + storage.set(KEY_BANNER_DISMISSED, "true") + _state.update { it.copy(isNotificationBannerDismissed = true) } + } + } + + private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) { + val secret = storage.getSecret(KEY_APP_KEYS) + if (secret != null) return@withContext Keys.parse(secret) + val keys = Keys.generate() + storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32()) + keys + } + + private suspend fun createSigner( + secret: String, + password: String? = null + ): Pair = withContext(defaultDispatcher) { + when { + secret.startsWith("nsec1") -> Keys.parse(secret) to null + + secret.startsWith("ncryptsec1") -> { + if (password == null) throw IllegalArgumentException("Password is required") + val enc = EncryptedSecretKey.fromBech32(secret) + val decrypted = enc.decrypt(password) + val keys = Keys(decrypted) + keys to keys.secretKey().toBech32() + } + + secret.startsWith("bunker://") -> { + val appKeys = getOrInitAppKeys() + val bunker = NostrConnectUri.parse(secret) + val timeout = 50.seconds + NostrConnect(uri = bunker, appKeys, timeout, null) to null + } + + secret.startsWith("nip55://") -> { + val handler = externalSignerHandler ?: throw IllegalStateException("Not available") + val parts = secret.removePrefix("nip55://").split("/", limit = 2) + val packageName = parts[0] + val pubkey = PublicKey.parse(parts[1]) + + handler.setPackageName(packageName) + ExternalSignerProxy(handler, pubkey) to null + } + + else -> throw IllegalArgumentException("Invalid secret format") + } + } + + fun isExternalSignerAvailable(): Boolean { + return externalSignerHandler?.isAvailable() == true + } + + fun importIdentity(secret: String, password: String? = null) { + scope.launch { + _state.update { it.copy(isImporting = true, importError = null) } + try { + val (signer, decryptedSecret) = createSigner(secret, password) + + nostr.setSigner(signer) + onSignerReady() + + storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret) + _state.update { it.copy(signerRequired = false, isImporting = false) } + } catch (e: Exception) { + onError("Import failed: ${e.message}") + _state.update { it.copy(isImporting = false, importError = e.message) } + } + } + } + + fun connectExternalSigner() { + scope.launch { + _state.update { it.copy(isImporting = true, importError = null) } + try { + val handler = + externalSignerHandler ?: throw IllegalStateException("Signer not available") + + val permissions = SignerPermissions.toJson( + listOf( + SignerPermissions.signEvent(0), + SignerPermissions.signEvent(3), + SignerPermissions.signEvent(10000), + SignerPermissions.signEvent(10050), + SignerPermissions.signEvent(10063), + SignerPermissions.signEvent(22242), + SignerPermissions.signEvent(30030), + SignerPermissions.signEvent(30315), + SignerPermissions.nip44Encrypt(), + SignerPermissions.nip44Decrypt(), + ) + ) + + val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected") + val signer = ExternalSignerProxy(handler, result.pubkey) + val uri = "nip55://${result.packageName}/${result.pubkey.toHex()}" + + nostr.setSigner(signer) + onSignerReady() + + storage.setSecret(KEY_USER_SIGNER, uri) + _state.update { it.copy(signerRequired = false, isImporting = false) } + } catch (e: Exception) { + onError("External signer connection failed: ${e.message}") + _state.update { it.copy(isImporting = false, importError = e.message) } + } + } + } + + fun createIdentity( + name: String, + bio: String?, + picture: ByteArray?, + contentType: String? = null + ) { + scope.launch { + _state.update { it.copy(isImporting = true, importError = null) } + try { + val keys = Keys.generate() + val secret = keys.secretKey().toBech32() + val avatarUrl = picture?.let { + mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg") + } + + nostr.profiles.createIdentity( + keys = keys, + name = name, + bio = bio, + picture = avatarUrl + ) + onSignerReady() + + storage.setSecret(KEY_USER_SIGNER, secret) + _state.update { it.copy(signerRequired = false, isImporting = false) } + } catch (e: Exception) { + 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/account/AccountRelayDelegate.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountRelayDelegate.kt new file mode 100644 index 0000000..99621aa --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountRelayDelegate.kt @@ -0,0 +1,185 @@ +package su.reya.coop.viewmodel.account + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +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 AccountRelayDelegate( + private val nostr: Nostr, + private val onError: (String) -> Unit, + private val scope: CoroutineScope, +) { + private val _isRelayListEmpty = MutableStateFlow(false) + val isRelayListEmpty = _isRelayListEmpty.asStateFlow() + + private val _currentUserRelayList = MutableStateFlow>(emptyMap()) + val currentUserRelayList = _currentUserRelayList.asStateFlow() + + private val _currentUserMsgRelayList = MutableStateFlow>(emptyList()) + val currentUserMsgRelayList = _currentUserMsgRelayList.asStateFlow() + + fun reset() { + _isRelayListEmpty.value = false + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun checkRelayList() { + scope.launch { + val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first() + println("user: ${currentUser.toBech32()}") + + // Small delay to ensure subscription is ready + delay(6.seconds) + + val relays = nostr.relays.getMsgRelays(currentUser) + if (relays.isEmpty()) _isRelayListEmpty.value = true + } + } + + fun dismissRelayWarning() { + _isRelayListEmpty.value = false + } + + fun refetchMsgRelays() { + scope.launch { + val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch + val relays = nostr.relays.fetchMsgRelays(currentUser) + + if (relays.isNotEmpty()) dismissRelayWarning() + } + } + + fun useDefaultMsgRelayList() { + scope.launch { + try { + val defaultRelays = nostr.relays.getDefaultMsgRelayList() + nostr.relays.setMsgRelays(defaultRelays) + } catch (e: Exception) { + onError("Error: ${e.message}") + } + } + } + + fun loadCurrentUserRelayList() { + scope.launch { + try { + val currentUser = + nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + _currentUserRelayList.value = nostr.relays.getRelayList(currentUser) + } catch (e: Exception) { + onError("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) { + onError("Error: ${e.message}") + return emptyMap() + } + } + + fun addInboxRelay(relay: String) { + scope.launch { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserRelayListInternal().toMutableMap() + relays[relayUrl] = RelayMetadata.WRITE + + nostr.relays.setRelaylist(relays) + } catch (e: Exception) { + onError("Error: ${e.message}") + } + } + } + + fun addOutboxRelay(relay: String) { + scope.launch { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserRelayListInternal().toMutableMap() + relays[relayUrl] = RelayMetadata.READ + + nostr.relays.setRelaylist(relays) + } catch (e: Exception) { + onError("Error: ${e.message}") + } + } + } + + fun removeRelay(relay: String) { + scope.launch { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserRelayListInternal().toMutableMap() + relays.remove(relayUrl) + + nostr.relays.setRelaylist(relays) + } catch (e: Exception) { + onError("Error: ${e.message}") + } + } + } + + fun loadCurrentUserMsgRelayList() { + scope.launch { + try { + val currentUser = + nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + _currentUserMsgRelayList.value = nostr.relays.getMsgRelays(currentUser) + } catch (e: Exception) { + onError("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) { + onError("Error: ${e.message}") + return emptyList() + } + } + + fun addMsgRelay(relay: String) { + scope.launch { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserMsgRelayListInternal().toMutableSet() + relays.add(relayUrl) + + nostr.relays.setMsgRelays(relays.toList()) + } catch (e: Exception) { + onError("Error: ${e.message}") + } + } + } + + fun removeMsgRelay(relay: String) { + scope.launch { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserMsgRelayListInternal().toMutableSet() + relays.remove(relayUrl) + + nostr.relays.setMsgRelays(relays.toList()) + } catch (e: Exception) { + 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) +}