This commit is contained in:
2026-07-11 16:41:39 +07:00
parent c5b76e9b2f
commit f2c1587efa
19 changed files with 528 additions and 381 deletions

View File

@@ -35,7 +35,6 @@ import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import su.reya.coop.screens.chat.ChatScreen
import su.reya.coop.screens.ContactListScreen import su.reya.coop.screens.ContactListScreen
import su.reya.coop.screens.HomeScreen import su.reya.coop.screens.HomeScreen
import su.reya.coop.screens.ImportScreen import su.reya.coop.screens.ImportScreen
@@ -48,10 +47,10 @@ import su.reya.coop.screens.RelayScreen
import su.reya.coop.screens.RequestListScreen import su.reya.coop.screens.RequestListScreen
import su.reya.coop.screens.ScanScreen import su.reya.coop.screens.ScanScreen
import su.reya.coop.screens.UpdateProfileScreen 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.ChatViewModel
import su.reya.coop.viewmodel.NostrViewModel import su.reya.coop.viewmodel.NostrViewModel
import su.reya.coop.viewmodel.RelayViewModel import su.reya.coop.viewmodel.account.AccountViewModel
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> { val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
error("No NostrViewModel provided") error("No NostrViewModel provided")
@@ -61,13 +60,10 @@ val LocalChatViewModel = staticCompositionLocalOf<ChatViewModel> {
error("No ChatViewModel provided") error("No ChatViewModel provided")
} }
val LocalAuthViewModel = staticCompositionLocalOf<AuthViewModel> { val LocalAccountViewModel = staticCompositionLocalOf<AccountViewModel> {
error("No AuthViewModel provided") error("No AccountViewModel provided")
} }
val LocalRelayViewModel = staticCompositionLocalOf<RelayViewModel> {
error("No RelayViewModel provided")
}
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> { val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided") error("No SnackbarHostState provided")
@@ -85,9 +81,8 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
@Composable @Composable
fun App( fun App(
nostrViewModel: NostrViewModel, nostrViewModel: NostrViewModel,
relayViewModel: RelayViewModel,
chatViewModel: ChatViewModel, chatViewModel: ChatViewModel,
authViewModel: AuthViewModel, accountViewModel: AccountViewModel,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val activity = context as? ComponentActivity val activity = context as? ComponentActivity
@@ -96,8 +91,8 @@ fun App(
val qrScanResult = remember { QrScanResult() } val qrScanResult = remember { QrScanResult() }
// Get the signer required state // Get the signer required state
val authState by authViewModel.state.collectAsStateWithLifecycle() val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val signerRequired = authState.signerRequired val signerRequired = accountState.signerRequired
// Snackbar // Snackbar
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -125,7 +120,7 @@ fun App(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
launch { launch {
authViewModel.errorEvents.collect { message -> accountViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message) snackbarHostState.showSnackbar(message)
} }
} }
@@ -139,11 +134,6 @@ fun App(
snackbarHostState.showSnackbar(message) snackbarHostState.showSnackbar(message)
} }
} }
launch {
relayViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
} }
LaunchedEffect(activity) { LaunchedEffect(activity) {
@@ -185,9 +175,8 @@ fun App(
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalNostrViewModel provides nostrViewModel, LocalNostrViewModel provides nostrViewModel,
LocalRelayViewModel provides relayViewModel,
LocalChatViewModel provides chatViewModel, LocalChatViewModel provides chatViewModel,
LocalAuthViewModel provides authViewModel, LocalAccountViewModel provides accountViewModel,
LocalSnackbarHostState provides snackbarHostState, LocalSnackbarHostState provides snackbarHostState,
LocalNavigator provides navigator, LocalNavigator provides navigator,
LocalScanResult provides qrScanResult, LocalScanResult provides qrScanResult,

View File

@@ -14,10 +14,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import su.reya.coop.nostr.NostrManager import su.reya.coop.nostr.NostrManager
import su.reya.coop.repository.MediaRepository import su.reya.coop.repository.MediaRepository
import su.reya.coop.viewmodel.AuthViewModel
import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.NostrViewModel import su.reya.coop.viewmodel.NostrViewModel
import su.reya.coop.viewmodel.RelayViewModel import su.reya.coop.viewmodel.account.AccountViewModel
import kotlin.system.exitProcess import kotlin.system.exitProcess
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -29,20 +28,18 @@ class MainActivity : ComponentActivity() {
object : ViewModelProvider.Factory { object : ViewModelProvider.Factory {
private val storage = AppStore(this@MainActivity) private val storage = AppStore(this@MainActivity)
private val mediaRepository = MediaRepository() private val mediaRepository = MediaRepository()
private val nostrViewModel = NostrViewModel(NostrManager.instance, mediaRepository) private val nostrViewModel = NostrViewModel(NostrManager.instance)
private val relayViewModel = RelayViewModel(NostrManager.instance)
private val chatViewModel = ChatViewModel(NostrManager.instance, mediaRepository) private val chatViewModel = ChatViewModel(NostrManager.instance, mediaRepository)
private val androidSigner = private val androidSigner =
AndroidExternalSigner(this@MainActivity, externalSignerLauncher) AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
private val authViewModel = private val accountViewModel =
AuthViewModel(NostrManager.instance, storage, mediaRepository, androidSigner) AccountViewModel(NostrManager.instance, storage, mediaRepository, androidSigner)
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when { return when {
modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel
modelClass.isAssignableFrom(RelayViewModel::class.java) -> relayViewModel
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel
modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel modelClass.isAssignableFrom(AccountViewModel::class.java) -> accountViewModel
else -> throw IllegalArgumentException("Unknown ViewModel class") else -> throw IllegalArgumentException("Unknown ViewModel class")
} as T } as T
} }
@@ -50,9 +47,8 @@ class MainActivity : ComponentActivity() {
} }
private val nostrViewModel: NostrViewModel by viewModels { factory } private val nostrViewModel: NostrViewModel by viewModels { factory }
private val relayViewModel: RelayViewModel by viewModels { factory }
private val chatViewModel: ChatViewModel 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?) { override fun onCreate(savedInstanceState: Bundle?) {
Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
@@ -91,15 +87,14 @@ class MainActivity : ComponentActivity() {
// Keep the splash screen visible until the signer check is complete // Keep the splash screen visible until the signer check is complete
splashScreen.setKeepOnScreenCondition { splashScreen.setKeepOnScreenCondition {
authViewModel.state.value.signerRequired == null accountViewModel.state.value.signerRequired == null
} }
setContent { setContent {
App( App(
nostrViewModel = nostrViewModel, nostrViewModel = nostrViewModel,
relayViewModel = relayViewModel,
chatViewModel = chatViewModel, chatViewModel = chatViewModel,
authViewModel = authViewModel, accountViewModel = accountViewModel,
) )
} }
} }

View File

@@ -58,6 +58,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Nip05Address import rust.nostr.sdk.Nip05Address
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
@@ -72,9 +73,10 @@ fun ContactListScreen() {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val accountViewModel = LocalAccountViewModel.current
val chatViewModel = LocalChatViewModel.current val chatViewModel = LocalChatViewModel.current
val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle() val contactList by accountViewModel.contactList.collectAsStateWithLifecycle()
var openAddContactDialog by remember { mutableStateOf(false) } var openAddContactDialog by remember { mutableStateOf(false) }
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) } var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
@@ -201,7 +203,7 @@ fun ContactListScreen() {
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
contactToDelete?.let { nostrViewModel.removeContact(it) } contactToDelete?.let { accountViewModel.removeContact(it) }
contactToDelete = null contactToDelete = null
} }
) { ) {
@@ -222,6 +224,7 @@ fun ContactListScreen() {
fun AddContactDialog(onDismissRequest: () -> Unit) { fun AddContactDialog(onDismissRequest: () -> Unit) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val accountViewModel = LocalAccountViewModel.current
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
var contact by remember { mutableStateOf("") } var contact by remember { mutableStateOf("") }
var isError by remember { mutableStateOf(false) } var isError by remember { mutableStateOf(false) }
@@ -265,7 +268,7 @@ fun AddContactDialog(onDismissRequest: () -> Unit) {
}, },
actions = { actions = {
IconButton(onClick = { IconButton(onClick = {
nostrViewModel.addContact(contact) accountViewModel.addContact(contact)
onDismissRequest() onDismissRequest()
}) { }) {
Icon( Icon(

View File

@@ -89,11 +89,10 @@ import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalAuthViewModel import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalRelayViewModel
import su.reya.coop.LocalScanResult import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room import su.reya.coop.Room
@@ -114,23 +113,22 @@ fun HomeScreen() {
val clipboardManager = LocalClipboard.current val clipboardManager = LocalClipboard.current
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current val chatViewModel = LocalChatViewModel.current
val authViewModel = LocalAuthViewModel.current val accountViewModel = LocalAccountViewModel.current
val relayViewModel = LocalRelayViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(true) val sheetState = rememberModalBottomSheetState(true)
val listState = rememberLazyListState() val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
val userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() val userProfile by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
val isRelayListEmpty by relayViewModel.isRelayListEmpty.collectAsStateWithLifecycle() val isRelayListEmpty by accountViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle() val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
val authState by authViewModel.state.collectAsStateWithLifecycle() val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val isBannerDismissed = authState.isNotificationBannerDismissed val isBannerDismissed = accountState.isNotificationBannerDismissed
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
@@ -157,7 +155,7 @@ fun HomeScreen() {
onPauseOrDispose { } onPauseOrDispose { }
} }
LaunchedEffect(authState.signerRequired) { LaunchedEffect(accountState.signerRequired) {
chatViewModel.refreshChatRooms() chatViewModel.refreshChatRooms()
} }
@@ -282,7 +280,7 @@ fun HomeScreen() {
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
TextButton( TextButton(
onClick = { authViewModel.dismissNotificationBanner() }, onClick = { accountViewModel.dismissNotificationBanner() },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { ) {
Text(text = "Maybe later") Text(text = "Maybe later")
@@ -469,7 +467,7 @@ fun HomeScreen() {
// Show the relay setup dialog if the msg relay list is empty // Show the relay setup dialog if the msg relay list is empty
if (isRelayListEmpty) { if (isRelayListEmpty) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { relayViewModel.dismissRelayWarning() }, onDismissRequest = { accountViewModel.dismissRelayWarning() },
sheetState = sheetState, sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
) { ) {
@@ -574,7 +572,7 @@ fun HomeScreen() {
enabled = !isBusy, enabled = !isBusy,
onClick = { onClick = {
isBusy = true isBusy = true
relayViewModel.refetchMsgRelays() accountViewModel.refetchMsgRelays()
isBusy = false isBusy = false
}, },
modifier = Modifier modifier = Modifier
@@ -589,7 +587,7 @@ fun HomeScreen() {
Button( Button(
enabled = !isBusy, enabled = !isBusy,
onClick = { onClick = {
relayViewModel.useDefaultMsgRelayList() accountViewModel.useDefaultMsgRelayList()
scope.launch { sheetState.hide() } scope.launch { sheetState.hide() }
}, },
modifier = Modifier modifier = Modifier
@@ -738,7 +736,7 @@ fun BottomMenuList(
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current val chatViewModel = LocalChatViewModel.current
val authViewModel = LocalAuthViewModel.current val accountViewModel = LocalAccountViewModel.current
val defaultMenuList = listOf( val defaultMenuList = listOf(
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) }, "Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
@@ -770,8 +768,8 @@ fun BottomMenuList(
FilledTonalButton( FilledTonalButton(
onClick = { onClick = {
onDismiss { onDismiss {
authViewModel.logout(onLogout = { accountViewModel.logout(onLogout = {
nostrViewModel.resetInternalState() accountViewModel.resetInternalState()
chatViewModel.resetInternalState() chatViewModel.resetInternalState()
}) })
} }

View File

@@ -50,7 +50,7 @@ import coop.composeapp.generated.resources.ic_scanner
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Keys import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.NostrConnectUri
import su.reya.coop.LocalAuthViewModel import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalScanResult import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
@@ -63,9 +63,9 @@ fun ImportScreen() {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current val qrScanResult = LocalScanResult.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val authViewModel = LocalAuthViewModel.current val accountViewModel = LocalAccountViewModel.current
val authState by authViewModel.state.collectAsStateWithLifecycle() val accountState by accountViewModel.state.collectAsStateWithLifecycle()
var secret by remember { mutableStateOf("") } var secret by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
@@ -98,15 +98,15 @@ fun ImportScreen() {
} }
// Navigate to Home on successful import (signerRequired becomes false) // Navigate to Home on successful import (signerRequired becomes false)
LaunchedEffect(authState.signerRequired) { LaunchedEffect(accountState.signerRequired) {
if (authState.signerRequired == false) { if (accountState.signerRequired == false) {
navigator.navigate(Screen.Home) navigator.navigate(Screen.Home)
} }
} }
// Show import errors via snackbar // Show import errors via snackbar
LaunchedEffect(authState.importError) { LaunchedEffect(accountState.importError) {
authState.importError?.let { accountState.importError?.let {
snackbarHostState.showSnackbar(it) snackbarHostState.showSnackbar(it)
} }
} }
@@ -177,7 +177,7 @@ fun ImportScreen() {
BasicTextField( BasicTextField(
value = secret, value = secret,
onValueChange = { secret = it }, onValueChange = { secret = it },
enabled = !authState.isImporting, enabled = !accountState.isImporting,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
@@ -222,7 +222,7 @@ fun ImportScreen() {
BasicTextField( BasicTextField(
value = password, value = password,
onValueChange = { password = it }, onValueChange = { password = it },
enabled = !authState.isImporting && requirePassword, enabled = !accountState.isImporting && requirePassword,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
@@ -250,14 +250,14 @@ fun ImportScreen() {
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
Button( Button(
onClick = { onClick = {
authViewModel.importIdentity(secret, password) accountViewModel.importIdentity(secret, password)
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight), .height(ButtonDefaults.MediumContainerHeight),
enabled = secret.isNotBlank() && !authState.isImporting, enabled = secret.isNotBlank() && !accountState.isImporting,
) { ) {
if (authState.isImporting) { if (accountState.isImporting) {
LoadingIndicator() LoadingIndicator()
} else { } else {
Text( Text(

View File

@@ -21,16 +21,16 @@ import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import io.github.alexzhirkevich.qrose.rememberQrCodePainter import io.github.alexzhirkevich.qrose.rememberQrCodePainter
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
@Composable @Composable
fun MyQrScreen() { fun MyQrScreen() {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val nostrViewModel = LocalNostrViewModel.current val accountViewModel = LocalAccountViewModel.current
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() val currentUser by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },

View File

@@ -55,6 +55,7 @@ import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
@@ -72,9 +73,10 @@ fun NewChatScreen() {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current val qrScanResult = LocalScanResult.current
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val accountViewModel = LocalAccountViewModel.current
val chatViewModel = LocalChatViewModel.current val chatViewModel = LocalChatViewModel.current
val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle() val contactList by accountViewModel.contactList.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
val createGroup = remember { mutableStateOf(false) } val createGroup = remember { mutableStateOf(false) }
@@ -96,13 +98,13 @@ fun NewChatScreen() {
selectedReceivers.add(pubkey) selectedReceivers.add(pubkey)
} }
} else if (query.contains("@")) { } else if (query.contains("@")) {
nostrViewModel.searchByAddress(query) { pubkey -> accountViewModel.searchByAddress(query) { pubkey ->
if (pubkey != null) { if (pubkey != null) {
selectedReceivers.add(pubkey) selectedReceivers.add(pubkey)
} }
} }
} else { } else {
nostrViewModel.searchByNostr(query) { results -> accountViewModel.searchByNostr(query) { results ->
searchResults.clear() searchResults.clear()
searchResults.addAll(results) searchResults.addAll(results)
} }

View File

@@ -1,14 +1,14 @@
package su.reya.coop.screens package su.reya.coop.screens
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import su.reya.coop.LocalAuthViewModel import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.shared.ProfileEditor import su.reya.coop.shared.ProfileEditor
@Composable @Composable
fun NewIdentityScreen() { fun NewIdentityScreen() {
val authViewModel = LocalAuthViewModel.current val accountViewModel = LocalAccountViewModel.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
ProfileEditor( ProfileEditor(
@@ -16,7 +16,7 @@ fun NewIdentityScreen() {
buttonLabel = "Continue", buttonLabel = "Continue",
onBack = { navigator.goBack() }, onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type -> onConfirm = { name, bio, bytes, type ->
authViewModel.createIdentity(name, bio, bytes, type) accountViewModel.createIdentity(name, bio, bytes, type)
navigator.navigate(Screen.Home) navigator.navigate(Screen.Home)
} }
) )

View File

@@ -48,7 +48,7 @@ import coop.composeapp.generated.resources.coop
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.painterResource 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.LocalNavigator
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
@@ -60,21 +60,21 @@ fun OnboardingScreen() {
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val authViewModel = LocalAuthViewModel.current val accountViewModel = LocalAccountViewModel.current
val authState by authViewModel.state.collectAsStateWithLifecycle() val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Navigate to Home on successful external signer connection // Navigate to Home on successful external signer connection
LaunchedEffect(authState.signerRequired) { LaunchedEffect(accountState.signerRequired) {
if (authState.signerRequired == false) { if (accountState.signerRequired == false) {
navigator.navigate(Screen.Home) navigator.navigate(Screen.Home)
} }
} }
// Show connection errors // Show connection errors
LaunchedEffect(authState.importError) { LaunchedEffect(accountState.importError) {
authState.importError?.let { accountState.importError?.let {
snackbarHostState.showSnackbar(it) snackbarHostState.showSnackbar(it)
} }
} }
@@ -176,10 +176,10 @@ fun OnboardingScreen() {
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
FilledTonalButton( FilledTonalButton(
onClick = { onClick = {
if (authViewModel.isExternalSignerAvailable()) { if (accountViewModel.isExternalSignerAvailable()) {
// Connect to the external signer // Connect to the external signer
// TODO: show all available signers? // TODO: show all available signers?
authViewModel.connectExternalSigner() accountViewModel.connectExternalSigner()
} else { } else {
scope.launch { scope.launch {
val result = snackbarHostState.showSnackbar( val result = snackbarHostState.showSnackbar(

View File

@@ -68,7 +68,7 @@ import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.RelayUrl
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalRelayViewModel import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -76,7 +76,7 @@ import su.reya.coop.LocalSnackbarHostState
fun RelayScreen() { fun RelayScreen() {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val relayViewModel = LocalRelayViewModel.current val accountViewModel = LocalAccountViewModel.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -99,12 +99,12 @@ fun RelayScreen() {
var relayToDelete by remember { mutableStateOf<String?>(null) } var relayToDelete by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
relayViewModel.loadCurrentUserRelayList() accountViewModel.loadCurrentUserRelayList()
relayViewModel.loadCurrentUserMsgRelayList() accountViewModel.loadCurrentUserMsgRelayList()
} }
val loadedRelayList by relayViewModel.currentUserRelayList.collectAsStateWithLifecycle() val loadedRelayList by accountViewModel.currentUserRelayList.collectAsStateWithLifecycle()
val loadedMsgRelayList by relayViewModel.currentUserMsgRelayList.collectAsStateWithLifecycle() val loadedMsgRelayList by accountViewModel.currentUserMsgRelayList.collectAsStateWithLifecycle()
LaunchedEffect(loadedRelayList) { LaunchedEffect(loadedRelayList) {
if (loadedRelayList.isNotEmpty()) { if (loadedRelayList.isNotEmpty()) {
@@ -341,7 +341,7 @@ fun RelayScreen() {
relayToDelete = null relayToDelete = null
return@TextButton return@TextButton
} }
relayViewModel.removeMsgRelay(relayToDelete!!) accountViewModel.removeMsgRelay(relayToDelete!!)
msgRelayList.removeIf { it.toString() == relayToDelete } msgRelayList.removeIf { it.toString() == relayToDelete }
relayToDelete = null relayToDelete = null
} }
@@ -365,7 +365,7 @@ fun AddRelayDialog(
onMsgRelayAdded: (newRelay: String) -> Unit, onMsgRelayAdded: (newRelay: String) -> Unit,
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit, onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
) { ) {
val relayViewModel = LocalRelayViewModel.current val accountViewModel = LocalAccountViewModel.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -416,17 +416,17 @@ fun AddRelayDialog(
if (!isError) { if (!isError) {
when (selected) { when (selected) {
"Messaging" -> { "Messaging" -> {
relayViewModel.addMsgRelay(relayAddress) accountViewModel.addMsgRelay(relayAddress)
onMsgRelayAdded(relayAddress) onMsgRelayAdded(relayAddress)
} }
"Inbox" -> { "Inbox" -> {
relayViewModel.addInboxRelay(relayAddress) accountViewModel.addInboxRelay(relayAddress)
onRelayAdded(relayAddress, RelayMetadata.WRITE) onRelayAdded(relayAddress, RelayMetadata.WRITE)
} }
"Outbox" -> { "Outbox" -> {
relayViewModel.addOutboxRelay(relayAddress) accountViewModel.addOutboxRelay(relayAddress)
onRelayAdded(relayAddress, RelayMetadata.READ) onRelayAdded(relayAddress, RelayMetadata.READ)
} }
} }

View File

@@ -3,16 +3,16 @@ package su.reya.coop.screens
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.shared.ProfileEditor import su.reya.coop.shared.ProfileEditor
@Composable @Composable
fun UpdateProfileScreen() { fun UpdateProfileScreen() {
val nostrViewModel = LocalNostrViewModel.current val accountViewModel = LocalAccountViewModel.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() val currentUser by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
val profile = currentUser?.metadata?.asRecord() val profile = currentUser?.metadata?.asRecord()
ProfileEditor( ProfileEditor(
@@ -23,7 +23,7 @@ fun UpdateProfileScreen() {
initialPicture = profile?.picture, initialPicture = profile?.picture,
onBack = { navigator.goBack() }, onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type -> onConfirm = { name, bio, bytes, type ->
nostrViewModel.updateProfile(name, bio, bytes, type) accountViewModel.updateProfile(name, bio, bytes, type)
navigator.goBack() navigator.goBack()
} }
) )

View File

@@ -31,6 +31,7 @@ import coop.composeapp.generated.resources.ic_check_circle
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.Room import su.reya.coop.Room
import su.reya.coop.humanReadable import su.reya.coop.humanReadable
@@ -44,6 +45,7 @@ fun ScreenerCard(room: Room) {
val pubkey = room.members.firstOrNull() ?: return val pubkey = room.members.firstOrNull() ?: return
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val accountViewModel = LocalAccountViewModel.current
var isContact by remember { mutableStateOf(false) } var isContact by remember { mutableStateOf(false) }
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) } var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
@@ -54,11 +56,11 @@ fun ScreenerCard(room: Room) {
LaunchedEffect(pubkey) { LaunchedEffect(pubkey) {
// Check contact // Check contact
nostrViewModel.verifyContact(pubkey) { isContact = it } accountViewModel.verifyContact(pubkey) { isContact = it }
// Get mutual contacts // Get mutual contacts
nostrViewModel.mutualContacts(pubkey) { mutualContacts = it } accountViewModel.mutualContacts(pubkey) { mutualContacts = it }
// Get the last activity // Get the last activity
nostrViewModel.verifyActivity(pubkey) { lastActivity = it } accountViewModel.verifyActivity(pubkey) { lastActivity = it }
} }
Column( Column(

View File

@@ -65,6 +65,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
@@ -92,7 +93,8 @@ fun ChatScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
// Get current user // Get current user
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() val accountViewModel = LocalAccountViewModel.current
val currentUser by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
// Get chat room by ID // Get chat room by ID
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()

View File

@@ -1,57 +1,27 @@
package su.reya.coop.viewmodel package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp
import su.reya.coop.Profile import su.reya.coop.Profile
import su.reya.coop.nostr.Nostr import su.reya.coop.nostr.Nostr
import su.reya.coop.repository.MediaRepository
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
data class NostrAppState( class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
val isBusy: Boolean = false,
)
class NostrViewModel(
private val nostr: Nostr,
private val mediaRepository: MediaRepository,
) : BaseViewModel() {
private val _appState = MutableStateFlow(NostrAppState())
val appState: StateFlow<NostrAppState> = _appState.asStateFlow()
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList = _contactList.asStateFlow()
private val profilesMutex = Mutex() private val profilesMutex = Mutex()
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>() private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED) private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>() private val seenPublicKeys = mutableSetOf<PublicKey>()
@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 { init {
// Launch continuous background observers // Launch continuous background observers
viewModelScope.launch { runObserver() } viewModelScope.launch { runObserver() }
@@ -60,9 +30,6 @@ class NostrViewModel(
// Automatically reconnect bootstrap relays // Automatically reconnect bootstrap relays
reconnect() reconnect()
// Fetch metadata for the current user
fetchUserMetadata()
// Get all local stored metadata // Get all local stored metadata
getCacheMetadata() getCacheMetadata()
} }
@@ -75,13 +42,6 @@ class NostrViewModel(
} }
private suspend fun runObserver() = coroutineScope { private suspend fun runObserver() = coroutineScope {
// Observe contact list updates
launch {
nostr.profiles.contactListUpdates.collect { contacts ->
_contactList.value = contacts.toSet()
}
}
// Observe metadata updates // Observe metadata updates
launch { launch {
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) -> nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
@@ -143,20 +103,7 @@ class NostrViewModel(
} }
} }
} }
private fun fetchUserMetadata() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
// Wait until a signer is explicitly set (which updates publicKeyFlow)
val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first()
// Get all metadata for the current user
nostr.profiles.getUserMetadata()
}
}
private fun requestMetadata(pubkey: PublicKey) { private fun requestMetadata(pubkey: PublicKey) {
if (seenPublicKeys.add(pubkey)) { if (seenPublicKeys.add(pubkey)) {
metadataRequestChannel.trySend(pubkey) metadataRequestChannel.trySend(pubkey)
@@ -176,144 +123,4 @@ class NostrViewModel(
return flow.asStateFlow() return flow.asStateFlow()
} }
fun resetInternalState() {
_contactList.value = emptySet()
}
fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) {
viewModelScope.launch {
_appState.update { it.copy(isBusy = true) }
try {
val avatarUrl =
picture?.let {
mediaRepository.blossomUpload(
nostr.signer.get(),
it,
contentType ?: "image/jpeg"
)
}
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
val currentUser =
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
// Update the metadata state after successfully published
updateMetadata(currentUser, Profile(currentUser, newMetadata))
// Update local state
_appState.update { it.copy(isBusy = false) }
} catch (e: Exception) {
showError("Error: ${e.message}")
_appState.update { it.copy(isBusy = false) }
}
}
}
private fun newContact(publicKey: PublicKey) {
if (publicKey in contactList.value) return
viewModelScope.launch {
try {
val updated = contactList.value + publicKey
// Publish new event
nostr.profiles.setContactList(updated.toList())
// Optimistic local update
_contactList.update { it + publicKey }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun addContact(address: String) {
viewModelScope.launch {
val pubkey = try {
if (address.contains("@")) {
nostr.profiles.searchByAddress(address)
} else {
PublicKey.parse(address)
}
} catch (e: Exception) {
showError("Invalid contact address: ${e.message}")
return@launch
}
newContact(pubkey)
}
}
fun removeContact(publicKey: PublicKey) {
viewModelScope.launch {
if (publicKey !in contactList.value) return@launch
try {
val updated = contactList.value - publicKey
// Publish new event
nostr.profiles.setContactList(updated.toList())
// Optimistic local update
_contactList.update { it - publicKey }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) {
viewModelScope.launch {
try {
onResult(nostr.profiles.searchByAddress(query))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(null)
}
}
}
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) {
viewModelScope.launch {
try {
onResult(nostr.profiles.searchByNostr(query))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(emptyList())
}
}
}
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) {
viewModelScope.launch {
try {
onResult(nostr.profiles.verifyActivity(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(null)
}
}
}
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) {
viewModelScope.launch {
try {
onResult(nostr.profiles.verifyContact(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(false)
}
}
}
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) {
viewModelScope.launch {
try {
onResult(nostr.profiles.mutualContacts(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(emptySet())
}
}
}
} }

View File

@@ -1,9 +1,10 @@
package su.reya.coop.viewmodel package su.reya.coop.viewmodel.account
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -23,46 +24,46 @@ import su.reya.coop.nostr.SignerPermissions
import su.reya.coop.repository.MediaRepository import su.reya.coop.repository.MediaRepository
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
data class AuthState( data class AccountState(
val signerRequired: Boolean? = null, val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false, val isNotificationBannerDismissed: Boolean = false,
val isImporting: Boolean = false, val isImporting: Boolean = false,
val importError: String? = null, val importError: String? = null,
) )
class AuthViewModel( class AccountAuthDelegate(
private val nostr: Nostr, private val nostr: Nostr,
private val storage: AppStorage, private val storage: AppStorage,
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
private val externalSignerHandler: ExternalSignerHandler? = null, private val externalSignerHandler: ExternalSignerHandler? = null,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : BaseViewModel() { private val onError: (String) -> Unit,
private val onSignerReady: () -> Unit,
private val scope: CoroutineScope,
) {
companion object { companion object {
private const val KEY_USER_SIGNER = "user_signer" private const val KEY_USER_SIGNER = "user_signer"
private const val KEY_APP_KEYS = "app_keys" private const val KEY_APP_KEYS = "app_keys"
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed" private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
} }
private val _state = MutableStateFlow(AuthState()) private val _state = MutableStateFlow(AccountState())
val state = _state.asStateFlow() val state: StateFlow<AccountState> = _state.asStateFlow()
init { fun init() {
// Check if the notification banner has been dismissed
checkNotificationBannerDismissedStatus() checkNotificationBannerDismissedStatus()
// Check local stored secret (secret key or bunker)
login() login()
} }
private fun checkNotificationBannerDismissedStatus() { private fun checkNotificationBannerDismissedStatus() {
viewModelScope.launch { scope.launch {
val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true" val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
_state.update { it.copy(isNotificationBannerDismissed = dismissed) } _state.update { it.copy(isNotificationBannerDismissed = dismissed) }
} }
} }
private fun login() { private fun login() {
viewModelScope.launch { scope.launch {
try { try {
val secret = withTimeoutOrNull(5.seconds) { val secret = withTimeoutOrNull(5.seconds) {
storage.getSecret(KEY_USER_SIGNER) storage.getSecret(KEY_USER_SIGNER)
@@ -78,39 +79,36 @@ class AuthViewModel(
nostr.setSigner(signer) nostr.setSigner(signer)
}.onSuccess { }.onSuccess {
_state.update { it.copy(signerRequired = false) } _state.update { it.copy(signerRequired = false) }
onSignerReady()
}.onFailure { e -> }.onFailure { e ->
showError("Login failed: ${e.message}") onError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) } _state.update { it.copy(signerRequired = true) }
} }
} catch (e: Exception) { } catch (e: Exception) {
showError("Login failed: ${e.message}") onError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) } _state.update { it.copy(signerRequired = true) }
} }
} }
} }
fun logout(onLogout: () -> Unit = {}) { fun logout(onLogout: () -> Unit = {}) {
viewModelScope.launch { scope.launch {
try { try {
// Reset the nostr signer and prune the database
nostr.signer.switch(Keys.generate()) nostr.signer.switch(Keys.generate())
nostr.prune() nostr.prune()
} catch (e: Exception) { } catch (e: Exception) {
showError("Logout encountered an error: ${e.message}") onError("Logout encountered an error: ${e.message}")
} finally { } finally {
// Clear credentials from persistent storage
storage.clear(KEY_USER_SIGNER) storage.clear(KEY_USER_SIGNER)
storage.clear(KEY_BANNER_DISMISSED) storage.clear(KEY_BANNER_DISMISSED)
// Call cleanup callback (e.g. to reset other ViewModels)
onLogout() onLogout()
// Reset local states
_state.update { it.copy(signerRequired = true) } _state.update { it.copy(signerRequired = true) }
} }
} }
} }
fun dismissNotificationBanner() { fun dismissNotificationBanner() {
viewModelScope.launch { scope.launch {
storage.set(KEY_BANNER_DISMISSED, "true") storage.set(KEY_BANNER_DISMISSED, "true")
_state.update { it.copy(isNotificationBannerDismissed = true) } _state.update { it.copy(isNotificationBannerDismissed = true) }
} }
@@ -118,9 +116,7 @@ class AuthViewModel(
private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) { private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) {
val secret = storage.getSecret(KEY_APP_KEYS) val secret = storage.getSecret(KEY_APP_KEYS)
// If app keys are already stored, use them
if (secret != null) return@withContext Keys.parse(secret) if (secret != null) return@withContext Keys.parse(secret)
// Generate new app keys and save to the secret storage
val keys = Keys.generate() val keys = Keys.generate()
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32()) storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
keys keys
@@ -136,9 +132,8 @@ class AuthViewModel(
secret.startsWith("ncryptsec1") -> { secret.startsWith("ncryptsec1") -> {
if (password == null) throw IllegalArgumentException("Password is required") if (password == null) throw IllegalArgumentException("Password is required")
val enc = EncryptedSecretKey.fromBech32(secret) val enc = EncryptedSecretKey.fromBech32(secret)
val secret = enc.decrypt(password) val decrypted = enc.decrypt(password)
val keys = Keys(secret) val keys = Keys(decrypted)
keys to keys.secretKey().toBech32() keys to keys.secretKey().toBech32()
} }
@@ -153,7 +148,6 @@ class AuthViewModel(
val handler = externalSignerHandler val handler = externalSignerHandler
?: throw IllegalStateException("External signer not available on this platform") ?: throw IllegalStateException("External signer not available on this platform")
// Format: nip55://packageName/hexPubkey
val parts = secret.removePrefix("nip55://").split("/", limit = 2) val parts = secret.removePrefix("nip55://").split("/", limit = 2)
val packageName = parts[0] val packageName = parts[0]
val pubkey = PublicKey.parse(parts[1]) val pubkey = PublicKey.parse(parts[1])
@@ -167,25 +161,23 @@ class AuthViewModel(
} }
fun importIdentity(secret: String, password: String? = null) { fun importIdentity(secret: String, password: String? = null) {
viewModelScope.launch { scope.launch {
_state.update { it.copy(isImporting = true, importError = null) } _state.update { it.copy(isImporting = true, importError = null) }
try { try {
val (signer, decryptedSecret) = createSigner(secret, password) val (signer, decryptedSecret) = createSigner(secret, password)
// Update signer
nostr.setSigner(signer) nostr.setSigner(signer)
// Persist the secret in the secret storage
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret) storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
// Update local states
_state.update { it.copy(signerRequired = false, isImporting = false) } _state.update { it.copy(signerRequired = false, isImporting = false) }
onSignerReady()
} catch (e: Exception) { } catch (e: Exception) {
showError("Import failed: ${e.message}") onError("Import failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) } _state.update { it.copy(isImporting = false, importError = e.message) }
} }
} }
} }
fun connectExternalSigner() { fun connectExternalSigner() {
viewModelScope.launch { scope.launch {
_state.update { it.copy(isImporting = true, importError = null) } _state.update { it.copy(isImporting = true, importError = null) }
try { try {
val handler = externalSignerHandler val handler = externalSignerHandler
@@ -209,17 +201,15 @@ class AuthViewModel(
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected") val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
val signer = ExternalSignerProxy(handler, result.pubkey) val signer = ExternalSignerProxy(handler, result.pubkey)
// Update signer
nostr.setSigner(signer) nostr.setSigner(signer)
// Store the signer in the secret storage
storage.setSecret( storage.setSecret(
KEY_USER_SIGNER, KEY_USER_SIGNER,
"nip55://${result.packageName}/${result.pubkey.toHex()}" "nip55://${result.packageName}/${result.pubkey.toHex()}"
) )
// Update local states
_state.update { it.copy(signerRequired = false, isImporting = false) } _state.update { it.copy(signerRequired = false, isImporting = false) }
onSignerReady()
} catch (e: Exception) { } catch (e: Exception) {
showError("External signer connection failed: ${e.message}") onError("External signer connection failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) } _state.update { it.copy(isImporting = false, importError = e.message) }
} }
} }
@@ -235,7 +225,7 @@ class AuthViewModel(
picture: ByteArray?, picture: ByteArray?,
contentType: String? = null contentType: String? = null
) { ) {
viewModelScope.launch { scope.launch {
_state.update { it.copy(isImporting = true, importError = null) } _state.update { it.copy(isImporting = true, importError = null) }
try { try {
val keys = Keys.generate() val keys = Keys.generate()
@@ -243,19 +233,17 @@ class AuthViewModel(
val avatarUrl = picture?.let { val avatarUrl = picture?.let {
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg") mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
} }
// Create identity
nostr.profiles.createIdentity( nostr.profiles.createIdentity(
keys = keys, keys = keys,
name = name, name = name,
bio = bio, bio = bio,
picture = avatarUrl picture = avatarUrl
) )
// Persist the secret in the secret storage
storage.setSecret(KEY_USER_SIGNER, secret) storage.setSecret(KEY_USER_SIGNER, secret)
// Update local states
_state.update { it.copy(signerRequired = false, isImporting = false) } _state.update { it.copy(signerRequired = false, isImporting = false) }
onSignerReady()
} catch (e: Exception) { } catch (e: Exception) {
showError("Identity creation failed: ${e.message}") onError("Identity creation failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) } _state.update { it.copy(isImporting = false, importError = e.message) }
} }
} }

View File

@@ -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<Set<PublicKey>>(emptySet())
val contactList: StateFlow<Set<PublicKey>> = _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<PublicKey>) -> 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<PublicKey>) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.mutualContacts(pubkey))
} catch (e: Exception) {
onError("Error: ${e.message}")
onResult(emptySet())
}
}
}
}

View File

@@ -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<Boolean> = _isUpdatingProfile.asStateFlow()
@OptIn(ExperimentalCoroutinesApi::class)
val currentUserProfile: StateFlow<Profile?> = 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
}
}
}
}

View File

@@ -1,9 +1,9 @@
package su.reya.coop.viewmodel package su.reya.coop.viewmodel.account
import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -13,11 +13,13 @@ import rust.nostr.sdk.RelayUrl
import su.reya.coop.nostr.Nostr import su.reya.coop.nostr.Nostr
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class RelayViewModel( class AccountRelayDelegate(
private val nostr: Nostr, private val nostr: Nostr,
) : BaseViewModel() { private val onError: (String) -> Unit,
private val scope: CoroutineScope,
) {
private val _isRelayListEmpty = MutableStateFlow(false) private val _isRelayListEmpty = MutableStateFlow(false)
val isRelayListEmpty: StateFlow<Boolean> = _isRelayListEmpty.asStateFlow() val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
private val _currentUserRelayList = MutableStateFlow<Map<RelayUrl, RelayMetadata?>>(emptyMap()) private val _currentUserRelayList = MutableStateFlow<Map<RelayUrl, RelayMetadata?>>(emptyMap())
val currentUserRelayList = _currentUserRelayList.asStateFlow() val currentUserRelayList = _currentUserRelayList.asStateFlow()
@@ -25,22 +27,19 @@ class RelayViewModel(
private val _currentUserMsgRelayList = MutableStateFlow<List<RelayUrl>>(emptyList()) private val _currentUserMsgRelayList = MutableStateFlow<List<RelayUrl>>(emptyList())
val currentUserMsgRelayList = _currentUserMsgRelayList.asStateFlow() val currentUserMsgRelayList = _currentUserMsgRelayList.asStateFlow()
init { fun reset() {
checkRelayList() _isRelayListEmpty.value = false
} }
private fun checkRelayList() { @OptIn(ExperimentalCoroutinesApi::class)
viewModelScope.launch { fun checkRelayList() {
// Wait until the client is ready scope.launch {
nostr.waitUntilInitialized()
// Wait until a signer is explicitly set (which updates publicKeyFlow)
val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first() val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first()
println("user: ${currentUser.toBech32()}")
// Small delay to ensure all relays are connected // Small delay to ensure subscription is ready
delay(2.seconds) delay(6.seconds)
// Check if the relay list is empty
val relays = nostr.relays.getMsgRelays(currentUser) val relays = nostr.relays.getMsgRelays(currentUser)
if (relays.isEmpty()) _isRelayListEmpty.value = true if (relays.isEmpty()) _isRelayListEmpty.value = true
} }
@@ -50,12 +49,8 @@ class RelayViewModel(
_isRelayListEmpty.value = false _isRelayListEmpty.value = false
} }
fun resetInternalState() {
_isRelayListEmpty.value = false
}
fun refetchMsgRelays() { fun refetchMsgRelays() {
viewModelScope.launch { scope.launch {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch
val relays = nostr.relays.fetchMsgRelays(currentUser) val relays = nostr.relays.fetchMsgRelays(currentUser)
@@ -64,23 +59,24 @@ class RelayViewModel(
} }
fun useDefaultMsgRelayList() { fun useDefaultMsgRelayList() {
viewModelScope.launch { scope.launch {
try { try {
val defaultRelays = nostr.relays.getDefaultMsgRelayList() val defaultRelays = nostr.relays.getDefaultMsgRelayList()
nostr.relays.setMsgRelays(defaultRelays) nostr.relays.setMsgRelays(defaultRelays)
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") onError("Error: ${e.message}")
} }
} }
} }
fun loadCurrentUserRelayList() { fun loadCurrentUserRelayList() {
viewModelScope.launch { scope.launch {
try { try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") val currentUser =
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_currentUserRelayList.value = nostr.relays.getRelayList(currentUser) _currentUserRelayList.value = nostr.relays.getRelayList(currentUser)
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") onError("Error: ${e.message}")
} }
} }
} }
@@ -90,13 +86,13 @@ class RelayViewModel(
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getRelayList(currentUser) return nostr.relays.getRelayList(currentUser)
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") onError("Error: ${e.message}")
return emptyMap() return emptyMap()
} }
} }
fun addInboxRelay(relay: String) { fun addInboxRelay(relay: String) {
viewModelScope.launch { scope.launch {
try { try {
val relayUrl = RelayUrl.parse(relay) val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap() val relays = currentUserRelayListInternal().toMutableMap()
@@ -104,13 +100,13 @@ class RelayViewModel(
nostr.relays.setRelaylist(relays) nostr.relays.setRelaylist(relays)
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") onError("Error: ${e.message}")
} }
} }
} }
fun addOutboxRelay(relay: String) { fun addOutboxRelay(relay: String) {
viewModelScope.launch { scope.launch {
try { try {
val relayUrl = RelayUrl.parse(relay) val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap() val relays = currentUserRelayListInternal().toMutableMap()
@@ -118,13 +114,13 @@ class RelayViewModel(
nostr.relays.setRelaylist(relays) nostr.relays.setRelaylist(relays)
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") onError("Error: ${e.message}")
} }
} }
} }
fun removeRelay(relay: String) { fun removeRelay(relay: String) {
viewModelScope.launch { scope.launch {
try { try {
val relayUrl = RelayUrl.parse(relay) val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap() val relays = currentUserRelayListInternal().toMutableMap()
@@ -132,18 +128,19 @@ class RelayViewModel(
nostr.relays.setRelaylist(relays) nostr.relays.setRelaylist(relays)
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") onError("Error: ${e.message}")
} }
} }
} }
fun loadCurrentUserMsgRelayList() { fun loadCurrentUserMsgRelayList() {
viewModelScope.launch { scope.launch {
try { try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") val currentUser =
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_currentUserMsgRelayList.value = nostr.relays.getMsgRelays(currentUser) _currentUserMsgRelayList.value = nostr.relays.getMsgRelays(currentUser)
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") onError("Error: ${e.message}")
} }
} }
} }
@@ -153,13 +150,13 @@ class RelayViewModel(
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getMsgRelays(currentUser) return nostr.relays.getMsgRelays(currentUser)
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") onError("Error: ${e.message}")
return emptyList() return emptyList()
} }
} }
fun addMsgRelay(relay: String) { fun addMsgRelay(relay: String) {
viewModelScope.launch { scope.launch {
try { try {
val relayUrl = RelayUrl.parse(relay) val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayListInternal().toMutableSet() val relays = currentUserMsgRelayListInternal().toMutableSet()
@@ -167,13 +164,13 @@ class RelayViewModel(
nostr.relays.setMsgRelays(relays.toList()) nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") onError("Error: ${e.message}")
} }
} }
} }
fun removeMsgRelay(relay: String) { fun removeMsgRelay(relay: String) {
viewModelScope.launch { scope.launch {
try { try {
val relayUrl = RelayUrl.parse(relay) val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayListInternal().toMutableSet() val relays = currentUserMsgRelayListInternal().toMutableSet()
@@ -181,7 +178,7 @@ class RelayViewModel(
nostr.relays.setMsgRelays(relays.toList()) nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") onError("Error: ${e.message}")
} }
} }
} }

View File

@@ -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?> = profile.currentUserProfile
val isUpdatingProfile: StateFlow<Boolean> = profile.isUpdatingProfile
val contactList: StateFlow<Set<PublicKey>> = contacts.contactList
val isRelayListEmpty: StateFlow<Boolean> = relays.isRelayListEmpty
val currentUserRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> = relays.currentUserRelayList
val currentUserMsgRelayList: StateFlow<List<RelayUrl>> = 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<PublicKey>) -> 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<PublicKey>) -> 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)
}