chore: improve performance #41

Merged
reya merged 9 commits from fix-perf into master 2026-07-11 12:45:54 +00:00
19 changed files with 528 additions and 381 deletions
Showing only changes of commit f2c1587efa - Show all commits

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,57 +1,27 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp
import su.reya.coop.Profile
import su.reya.coop.nostr.Nostr
import su.reya.coop.repository.MediaRepository
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
data class NostrAppState(
val isBusy: Boolean = false,
)
class NostrViewModel(
private val nostr: Nostr,
private val mediaRepository: MediaRepository,
) : BaseViewModel() {
private val _appState = MutableStateFlow(NostrAppState())
val appState: StateFlow<NostrAppState> = _appState.asStateFlow()
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList = _contactList.asStateFlow()
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
private val profilesMutex = Mutex()
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
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 {
// Launch continuous background observers
viewModelScope.launch { runObserver() }
@@ -60,9 +30,6 @@ class NostrViewModel(
// Automatically reconnect bootstrap relays
reconnect()
// Fetch metadata for the current user
fetchUserMetadata()
// Get all local stored metadata
getCacheMetadata()
}
@@ -75,13 +42,6 @@ class NostrViewModel(
}
private suspend fun runObserver() = coroutineScope {
// Observe contact list updates
launch {
nostr.profiles.contactListUpdates.collect { contacts ->
_contactList.value = contacts.toSet()
}
}
// Observe metadata updates
launch {
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
@@ -144,19 +104,6 @@ class NostrViewModel(
}
}
private fun fetchUserMetadata() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
// Wait until a signer is explicitly set (which updates publicKeyFlow)
val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first()
// Get all metadata for the current user
nostr.profiles.getUserMetadata()
}
}
private fun requestMetadata(pubkey: PublicKey) {
if (seenPublicKeys.add(pubkey)) {
metadataRequestChannel.trySend(pubkey)
@@ -176,144 +123,4 @@ class NostrViewModel(
return flow.asStateFlow()
}
fun resetInternalState() {
_contactList.value = emptySet()
}
fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) {
viewModelScope.launch {
_appState.update { it.copy(isBusy = true) }
try {
val avatarUrl =
picture?.let {
mediaRepository.blossomUpload(
nostr.signer.get(),
it,
contentType ?: "image/jpeg"
)
}
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
val currentUser =
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
// Update the metadata state after successfully published
updateMetadata(currentUser, Profile(currentUser, newMetadata))
// Update local state
_appState.update { it.copy(isBusy = false) }
} catch (e: Exception) {
showError("Error: ${e.message}")
_appState.update { it.copy(isBusy = false) }
}
}
}
private fun newContact(publicKey: PublicKey) {
if (publicKey in contactList.value) return
viewModelScope.launch {
try {
val updated = contactList.value + publicKey
// Publish new event
nostr.profiles.setContactList(updated.toList())
// Optimistic local update
_contactList.update { it + publicKey }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun addContact(address: String) {
viewModelScope.launch {
val pubkey = try {
if (address.contains("@")) {
nostr.profiles.searchByAddress(address)
} else {
PublicKey.parse(address)
}
} catch (e: Exception) {
showError("Invalid contact address: ${e.message}")
return@launch
}
newContact(pubkey)
}
}
fun removeContact(publicKey: PublicKey) {
viewModelScope.launch {
if (publicKey !in contactList.value) return@launch
try {
val updated = contactList.value - publicKey
// Publish new event
nostr.profiles.setContactList(updated.toList())
// Optimistic local update
_contactList.update { it - publicKey }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) {
viewModelScope.launch {
try {
onResult(nostr.profiles.searchByAddress(query))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(null)
}
}
}
fun searchByNostr(query: String, onResult: (List<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.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -23,46 +24,46 @@ import su.reya.coop.nostr.SignerPermissions
import su.reya.coop.repository.MediaRepository
import kotlin.time.Duration.Companion.seconds
data class AuthState(
data class AccountState(
val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false,
val isImporting: Boolean = false,
val importError: String? = null,
)
class AuthViewModel(
class AccountAuthDelegate(
private val nostr: Nostr,
private val storage: AppStorage,
private val mediaRepository: MediaRepository,
private val externalSignerHandler: ExternalSignerHandler? = null,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : BaseViewModel() {
private val onError: (String) -> Unit,
private val onSignerReady: () -> Unit,
private val scope: CoroutineScope,
) {
companion object {
private const val KEY_USER_SIGNER = "user_signer"
private const val KEY_APP_KEYS = "app_keys"
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
}
private val _state = MutableStateFlow(AuthState())
val state = _state.asStateFlow()
private val _state = MutableStateFlow(AccountState())
val state: StateFlow<AccountState> = _state.asStateFlow()
init {
// Check if the notification banner has been dismissed
fun init() {
checkNotificationBannerDismissedStatus()
// Check local stored secret (secret key or bunker)
login()
}
private fun checkNotificationBannerDismissedStatus() {
viewModelScope.launch {
scope.launch {
val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
}
}
private fun login() {
viewModelScope.launch {
scope.launch {
try {
val secret = withTimeoutOrNull(5.seconds) {
storage.getSecret(KEY_USER_SIGNER)
@@ -78,39 +79,36 @@ class AuthViewModel(
nostr.setSigner(signer)
}.onSuccess {
_state.update { it.copy(signerRequired = false) }
onSignerReady()
}.onFailure { e ->
showError("Login failed: ${e.message}")
onError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
} catch (e: Exception) {
showError("Login failed: ${e.message}")
onError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
}
}
fun logout(onLogout: () -> Unit = {}) {
viewModelScope.launch {
scope.launch {
try {
// Reset the nostr signer and prune the database
nostr.signer.switch(Keys.generate())
nostr.prune()
} catch (e: Exception) {
showError("Logout encountered an error: ${e.message}")
onError("Logout encountered an error: ${e.message}")
} finally {
// Clear credentials from persistent storage
storage.clear(KEY_USER_SIGNER)
storage.clear(KEY_BANNER_DISMISSED)
// Call cleanup callback (e.g. to reset other ViewModels)
onLogout()
// Reset local states
_state.update { it.copy(signerRequired = true) }
}
}
}
fun dismissNotificationBanner() {
viewModelScope.launch {
scope.launch {
storage.set(KEY_BANNER_DISMISSED, "true")
_state.update { it.copy(isNotificationBannerDismissed = true) }
}
@@ -118,9 +116,7 @@ class AuthViewModel(
private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) {
val secret = storage.getSecret(KEY_APP_KEYS)
// If app keys are already stored, use them
if (secret != null) return@withContext Keys.parse(secret)
// Generate new app keys and save to the secret storage
val keys = Keys.generate()
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
keys
@@ -136,9 +132,8 @@ class AuthViewModel(
secret.startsWith("ncryptsec1") -> {
if (password == null) throw IllegalArgumentException("Password is required")
val enc = EncryptedSecretKey.fromBech32(secret)
val secret = enc.decrypt(password)
val keys = Keys(secret)
val decrypted = enc.decrypt(password)
val keys = Keys(decrypted)
keys to keys.secretKey().toBech32()
}
@@ -153,7 +148,6 @@ class AuthViewModel(
val handler = externalSignerHandler
?: throw IllegalStateException("External signer not available on this platform")
// Format: nip55://packageName/hexPubkey
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
val packageName = parts[0]
val pubkey = PublicKey.parse(parts[1])
@@ -167,25 +161,23 @@ class AuthViewModel(
}
fun importIdentity(secret: String, password: String? = null) {
viewModelScope.launch {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val (signer, decryptedSecret) = createSigner(secret, password)
// Update signer
nostr.setSigner(signer)
// Persist the secret in the secret storage
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
// Update local states
_state.update { it.copy(signerRequired = false, isImporting = false) }
onSignerReady()
} catch (e: Exception) {
showError("Import failed: ${e.message}")
onError("Import failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
fun connectExternalSigner() {
viewModelScope.launch {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val handler = externalSignerHandler
@@ -209,17 +201,15 @@ class AuthViewModel(
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
val signer = ExternalSignerProxy(handler, result.pubkey)
// Update signer
nostr.setSigner(signer)
// Store the signer in the secret storage
storage.setSecret(
KEY_USER_SIGNER,
"nip55://${result.packageName}/${result.pubkey.toHex()}"
)
// Update local states
_state.update { it.copy(signerRequired = false, isImporting = false) }
onSignerReady()
} catch (e: Exception) {
showError("External signer connection failed: ${e.message}")
onError("External signer connection failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
@@ -235,7 +225,7 @@ class AuthViewModel(
picture: ByteArray?,
contentType: String? = null
) {
viewModelScope.launch {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val keys = Keys.generate()
@@ -243,19 +233,17 @@ class AuthViewModel(
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
}
// Create identity
nostr.profiles.createIdentity(
keys = keys,
name = name,
bio = bio,
picture = avatarUrl
)
// Persist the secret in the secret storage
storage.setSecret(KEY_USER_SIGNER, secret)
// Update local states
_state.update { it.copy(signerRequired = false, isImporting = false) }
onSignerReady()
} catch (e: Exception) {
showError("Identity creation failed: ${e.message}")
onError("Identity creation failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}

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

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