chore: improve performance #41

Merged
reya merged 9 commits from fix-perf into master 2026-07-11 12:45:54 +00:00
34 changed files with 1238 additions and 915 deletions

View File

@@ -34,8 +34,7 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack 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 su.reya.coop.repository.ErrorRepository 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,9 +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.account.AccountViewModel
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> { val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
error("No NostrViewModel provided") error("No NostrViewModel provided")
@@ -60,10 +60,11 @@ 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 LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> { val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided") error("No SnackbarHostState provided")
} }
@@ -81,7 +82,7 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
fun App( fun App(
nostrViewModel: NostrViewModel, nostrViewModel: NostrViewModel,
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
@@ -90,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() }
@@ -118,8 +119,20 @@ fun App(
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
ErrorRepository.errors.collect { message -> launch {
snackbarHostState.showSnackbar(message) accountViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
launch {
chatViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
launch {
nostrViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
} }
} }
@@ -163,7 +176,7 @@ fun App(
CompositionLocalProvider( CompositionLocalProvider(
LocalNostrViewModel provides nostrViewModel, LocalNostrViewModel provides nostrViewModel,
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

@@ -4,12 +4,15 @@ import android.content.Intent
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class ExternalSignerLauncher { class ExternalSignerLauncher(
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
) {
private var launcher: ActivityResultLauncher<Intent>? = null private var launcher: ActivityResultLauncher<Intent>? = null
private var pendingResult: CompletableDeferred<ActivityResult>? = null private var pendingResult: CompletableDeferred<ActivityResult>? = null
private val mutex = Mutex() private val mutex = Mutex()
@@ -19,7 +22,7 @@ class ExternalSignerLauncher {
} }
suspend fun launch(intent: Intent): ActivityResult = mutex.withLock { suspend fun launch(intent: Intent): ActivityResult = mutex.withLock {
withContext(Dispatchers.Main) { withContext(mainDispatcher) {
val deferred = CompletableDeferred<ActivityResult>() val deferred = CompletableDeferred<ActivityResult>()
pendingResult = deferred pendingResult = deferred
launcher?.launch(intent) ?: throw IllegalStateException("Signer not registered") launcher?.launch(intent) ?: throw IllegalStateException("Signer not registered")

View File

@@ -13,9 +13,10 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ViewModel 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.viewmodel.AuthViewModel import su.reya.coop.repository.MediaRepository
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.account.AccountViewModel
import kotlin.system.exitProcess import kotlin.system.exitProcess
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -26,18 +27,19 @@ class MainActivity : ComponentActivity() {
private val factory by lazy { private val factory by lazy {
object : ViewModelProvider.Factory { object : ViewModelProvider.Factory {
private val storage = AppStore(this@MainActivity) private val storage = AppStore(this@MainActivity)
private val mediaRepository = MediaRepository()
private val nostrViewModel = NostrViewModel(NostrManager.instance) private val nostrViewModel = NostrViewModel(NostrManager.instance)
private val chatViewModel = ChatViewModel(NostrManager.instance) 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, 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(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
} }
@@ -46,7 +48,7 @@ class MainActivity : ComponentActivity() {
private val nostrViewModel: NostrViewModel by viewModels { factory } private val nostrViewModel: NostrViewModel 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 ->
@@ -85,14 +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,
chatViewModel = chatViewModel, chatViewModel = chatViewModel,
authViewModel = authViewModel, accountViewModel = accountViewModel,
) )
} }
} }

View File

@@ -14,6 +14,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -27,6 +28,8 @@ private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
class NostrForegroundService : Service() { class NostrForegroundService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
private val nostr by lazy { NostrManager.instance } private val nostr by lazy { NostrManager.instance }
private var notificationJob: Job? = null private var notificationJob: Job? = null

View File

@@ -33,7 +33,6 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTooltipState import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -59,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
@@ -73,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) }
@@ -202,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
} }
) { ) {
@@ -223,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) }
@@ -266,10 +268,8 @@ fun AddContactDialog(onDismissRequest: () -> Unit) {
}, },
actions = { actions = {
IconButton(onClick = { IconButton(onClick = {
scope.launch { accountViewModel.addContact(contact)
val success = nostrViewModel.addContact(contact) onDismissRequest()
if (success) onDismissRequest()
}
}) { }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_check), painter = painterResource(Res.drawable.ic_check),
@@ -328,7 +328,7 @@ fun ContactListItem(
) { ) {
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val profile by profileFlow.collectAsState(initial = null) val profile by profileFlow.collectAsStateWithLifecycle(initialValue = null)
SegmentedListItem( SegmentedListItem(
onClick = onClick, onClick = onClick,

View File

@@ -89,7 +89,7 @@ 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
@@ -113,22 +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 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 nostrViewModel.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) }
@@ -155,7 +155,7 @@ fun HomeScreen() {
onPauseOrDispose { } onPauseOrDispose { }
} }
LaunchedEffect(Unit) { LaunchedEffect(accountState.signerRequired) {
chatViewModel.refreshChatRooms() chatViewModel.refreshChatRooms()
} }
@@ -280,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")
@@ -319,11 +319,9 @@ fun HomeScreen() {
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
state = pullToRefreshState, state = pullToRefreshState,
onRefresh = { onRefresh = {
scope.launch { isRefreshing = true
isRefreshing = true chatViewModel.refreshChatRooms()
chatViewModel.refreshChatRooms() isRefreshing = false
isRefreshing = false
}
}, },
indicator = { indicator = {
PullToRefreshDefaults.LoadingIndicator( PullToRefreshDefaults.LoadingIndicator(
@@ -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 = { nostrViewModel.dismissRelayWarning() }, onDismissRequest = { accountViewModel.dismissRelayWarning() },
sheetState = sheetState, sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
) { ) {
@@ -573,15 +571,9 @@ fun HomeScreen() {
TextButton( TextButton(
enabled = !isBusy, enabled = !isBusy,
onClick = { onClick = {
scope.launch { isBusy = true
isBusy = true accountViewModel.refetchMsgRelays()
try { isBusy = false
nostrViewModel.refetchMsgRelays()
} catch (e: Exception) {
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
}
isBusy = false
}
}, },
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
@@ -595,10 +587,8 @@ fun HomeScreen() {
Button( Button(
enabled = !isBusy, enabled = !isBusy,
onClick = { onClick = {
scope.launch { accountViewModel.useDefaultMsgRelayList()
nostrViewModel.useDefaultMsgRelayList() scope.launch { sheetState.hide() }
sheetState.hide()
}
}, },
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
@@ -746,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) },
@@ -778,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

@@ -34,7 +34,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -44,14 +43,14 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_scanner import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.launch
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
@@ -64,13 +63,13 @@ 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 scope = rememberCoroutineScope()
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("") }
var requirePassword by remember { mutableStateOf(false) } var requirePassword by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(false) }
LaunchedEffect(qrScanResult.content) { LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result -> qrScanResult.content?.let { result ->
@@ -98,6 +97,20 @@ fun ImportScreen() {
} }
} }
// Navigate to Home on successful import (signerRequired becomes false)
LaunchedEffect(accountState.signerRequired) {
if (accountState.signerRequired == false) {
navigator.navigate(Screen.Home)
}
}
// Show import errors via snackbar
LaunchedEffect(accountState.importError) {
accountState.importError?.let {
snackbarHostState.showSnackbar(it)
}
}
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -164,7 +177,7 @@ fun ImportScreen() {
BasicTextField( BasicTextField(
value = secret, value = secret,
onValueChange = { secret = it }, onValueChange = { secret = it },
enabled = !loading, enabled = !accountState.isImporting,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
@@ -209,7 +222,7 @@ fun ImportScreen() {
BasicTextField( BasicTextField(
value = password, value = password,
onValueChange = { password = it }, onValueChange = { password = it },
enabled = !loading && requirePassword, enabled = !accountState.isImporting && requirePassword,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
@@ -237,25 +250,14 @@ fun ImportScreen() {
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
Button( Button(
onClick = { onClick = {
scope.launch { accountViewModel.importIdentity(secret, password)
loading = true
try {
// Import the identity
authViewModel.importIdentity(secret, password)
// Navigate to the home screen
navigator.navigate(Screen.Home)
} catch (e: Exception) {
snackbarHostState.showSnackbar(e.message ?: "Error")
loading = false
}
}
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight), .height(ButtonDefaults.MediumContainerHeight),
enabled = secret.isNotBlank() && !loading, enabled = secret.isNotBlank() && !accountState.isImporting,
) { ) {
if (loading) { 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,14 +98,16 @@ fun NewChatScreen() {
selectedReceivers.add(pubkey) selectedReceivers.add(pubkey)
} }
} else if (query.contains("@")) { } else if (query.contains("@")) {
val pubkey = nostrViewModel.searchByAddress(query) accountViewModel.searchByAddress(query) { pubkey ->
if (pubkey != null) { if (pubkey != null) {
selectedReceivers.add(pubkey) selectedReceivers.add(pubkey)
}
} }
} else { } else {
val results = nostrViewModel.searchByNostr(query) accountViewModel.searchByNostr(query) { results ->
searchResults.clear() searchResults.clear()
searchResults.addAll(results) searchResults.addAll(results)
}
} }
query = "" query = ""

View File

@@ -1,22 +1,26 @@
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 androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
ProfileEditor( ProfileEditor(
title = "Create a new identity", title = "Create a new identity",
buttonLabel = "Continue", buttonLabel = "Continue",
isBusy = accountState.isImporting,
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

@@ -24,6 +24,8 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@@ -44,8 +46,9 @@ import androidx.core.net.toUri
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.coop import coop.composeapp.generated.resources.coop
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
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
@@ -57,9 +60,25 @@ 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 accountState by accountViewModel.state.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Navigate to Home on successful external signer connection
LaunchedEffect(accountState.signerRequired) {
if (accountState.signerRequired == false) {
navigator.navigate(Screen.Home)
}
}
// Show connection errors
LaunchedEffect(accountState.importError) {
accountState.importError?.let {
snackbarHostState.showSnackbar(it)
}
}
val logoPainter = painterResource(Res.drawable.coop) val logoPainter = painterResource(Res.drawable.coop)
val expressiveFont = getExpressiveFontFamily() val expressiveFont = getExpressiveFontFamily()
@@ -157,18 +176,12 @@ fun OnboardingScreen() {
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
FilledTonalButton( FilledTonalButton(
onClick = { onClick = {
scope.launch { if (accountViewModel.isExternalSignerAvailable()) {
if (authViewModel.isExternalSignerAvailable()) { // Connect to the external signer
try { // TODO: show all available signers?
// Connect to the external signer accountViewModel.connectExternalSigner()
// TODO: show all available signers? } else {
authViewModel.connectExternalSigner() scope.launch {
// Navigate to the home screen
navigator.navigate(Screen.Home)
} catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) }
}
} else {
val result = snackbarHostState.showSnackbar( val result = snackbarHostState.showSnackbar(
message = "External signer not installed. Please install Amber or alternatives.", message = "External signer not installed. Please install Amber or alternatives.",
actionLabel = "Install", actionLabel = "Install",

View File

@@ -56,6 +56,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_check import coop.composeapp.generated.resources.ic_check
@@ -67,14 +68,16 @@ 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.LocalAccountViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun RelayScreen() { fun RelayScreen() {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val accountViewModel = LocalAccountViewModel.current
val snackbarHostState = LocalSnackbarHostState.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val msgRelayList = remember { mutableStateListOf<RelayUrl>() } val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
@@ -96,8 +99,25 @@ fun RelayScreen() {
var relayToDelete by remember { mutableStateOf<String?>(null) } var relayToDelete by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
relayList.putAll(nostrViewModel.currentUserRelayList()) accountViewModel.loadCurrentUserRelayList()
msgRelayList.addAll(nostrViewModel.currentUserMsgRelayList()) accountViewModel.loadCurrentUserMsgRelayList()
}
val loadedRelayList by accountViewModel.currentUserRelayList.collectAsStateWithLifecycle()
val loadedMsgRelayList by accountViewModel.currentUserMsgRelayList.collectAsStateWithLifecycle()
LaunchedEffect(loadedRelayList) {
if (loadedRelayList.isNotEmpty()) {
relayList.clear()
relayList.putAll(loadedRelayList)
}
}
LaunchedEffect(loadedMsgRelayList) {
if (loadedMsgRelayList.isNotEmpty()) {
msgRelayList.clear()
msgRelayList.addAll(loadedMsgRelayList)
}
} }
Scaffold( Scaffold(
@@ -314,20 +334,16 @@ fun RelayScreen() {
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
scope.launch { if (msgRelayList.size == 1) {
if (msgRelayList.size == 1) { scope.launch {
snackbarHostState.showSnackbar("You must have at least one relay") snackbarHostState.showSnackbar("You must have at least one relay")
relayToDelete = null
return@launch
}
try {
nostrViewModel.removeMsgRelay(relayToDelete!!)
msgRelayList.removeIf { it.toString() == relayToDelete }
relayToDelete = null
} catch (e: Exception) {
snackbarHostState.showSnackbar("Failed to remove relay: ${e.message}")
} }
relayToDelete = null
return@TextButton
} }
accountViewModel.removeMsgRelay(relayToDelete!!)
msgRelayList.removeIf { it.toString() == relayToDelete }
relayToDelete = null
} }
) { ) {
Text("Confirm") Text("Confirm")
@@ -349,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 nostrViewModel = LocalNostrViewModel.current val accountViewModel = LocalAccountViewModel.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -397,26 +413,24 @@ fun AddRelayDialog(
}, },
actions = { actions = {
IconButton(onClick = { IconButton(onClick = {
scope.launch { if (!isError) {
if (!isError) { when (selected) {
when (selected) { "Messaging" -> {
"Messaging" -> { accountViewModel.addMsgRelay(relayAddress)
nostrViewModel.addMsgRelay(relayAddress) onMsgRelayAdded(relayAddress)
onMsgRelayAdded(relayAddress) }
}
"Inbox" -> {
"Inbox" -> { accountViewModel.addInboxRelay(relayAddress)
nostrViewModel.addInboxRelay(relayAddress) onRelayAdded(relayAddress, RelayMetadata.WRITE)
onRelayAdded(relayAddress, RelayMetadata.WRITE) }
}
"Outbox" -> {
"Outbox" -> { accountViewModel.addOutboxRelay(relayAddress)
nostrViewModel.addOutboxRelay(relayAddress) onRelayAdded(relayAddress, RelayMetadata.READ)
onRelayAdded(relayAddress, RelayMetadata.READ)
}
} }
onDismissRequest()
} }
onDismissRequest()
} }
}) { }) {
Icon( Icon(

View File

@@ -101,11 +101,9 @@ fun RequestListScreen() {
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
state = pullToRefreshState, state = pullToRefreshState,
onRefresh = { onRefresh = {
scope.launch { isRefreshing = true
isRefreshing = true chatViewModel.refreshChatRooms()
chatViewModel.refreshChatRooms() isRefreshing = false
isRefreshing = false
}
}, },
indicator = { indicator = {
PullToRefreshDefaults.LoadingIndicator( PullToRefreshDefaults.LoadingIndicator(

View File

@@ -3,17 +3,18 @@ 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()
val isUpdatingProfile by accountViewModel.isUpdatingProfile.collectAsStateWithLifecycle()
ProfileEditor( ProfileEditor(
title = "Update profile", title = "Update profile",
@@ -21,9 +22,10 @@ fun UpdateProfileScreen() {
initialName = profile?.displayName ?: profile?.name ?: "", initialName = profile?.displayName ?: profile?.name ?: "",
initialBio = profile?.about ?: "", initialBio = profile?.about ?: "",
initialPicture = profile?.picture, initialPicture = profile?.picture,
isBusy = isUpdatingProfile,
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

@@ -18,7 +18,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -29,10 +28,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_cancel import coop.composeapp.generated.resources.ic_cancel
import coop.composeapp.generated.resources.ic_check_circle import coop.composeapp.generated.resources.ic_check_circle
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 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
@@ -46,7 +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 scope = rememberCoroutineScope() 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()) }
@@ -56,14 +55,12 @@ fun ScreenerCard(room: Room) {
val profile by profileFlow.collectAsStateWithLifecycle() val profile by profileFlow.collectAsStateWithLifecycle()
LaunchedEffect(pubkey) { LaunchedEffect(pubkey) {
scope.launch { // Check contact
// Check contact accountViewModel.verifyContact(pubkey) { isContact = it }
nostrViewModel.verifyContact(pubkey).let { isContact = it } // Get mutual contacts
// Get mutual contacts accountViewModel.mutualContacts(pubkey) { mutualContacts = it }
nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it } // Get the last activity
// Get the last activity accountViewModel.verifyActivity(pubkey) { lastActivity = it }
nostrViewModel.verifyActivity(pubkey)?.let { lastActivity = it }
}
} }
Column( Column(

View File

@@ -59,11 +59,13 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch 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
@@ -77,7 +79,11 @@ import su.reya.coop.shared.Avatar
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ChatScreen(id: Long, screening: Boolean = false) { fun ChatScreen(
id: Long,
screening: Boolean = false,
coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
@@ -87,7 +93,8 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
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()
@@ -120,20 +127,16 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
val sendFile = { uri: Uri -> val sendFile = { uri: Uri ->
scope.launch { scope.launch {
try { // Read file on IO dispatcher
// Read file val file = withContext(coroutineDispatcher) {
val file = withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}
// Parse the file content type
val type = context.contentResolver.getType(uri)
// Send message
chatViewModel.sendFileMessage(id, file, type)
} catch (e: Exception) {
snackbarHostState.showSnackbar("Error: ${e.message}")
} }
// Parse the file content type
val type = context.contentResolver.getType(uri)
// Send message (handles errors internally via ViewModel)
chatViewModel.sendFileMessage(id, file, type)
} }
} }
@@ -153,12 +156,13 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
LaunchedEffect(id) { LaunchedEffect(id) {
// Get messages // Get messages
val initialMessages = chatViewModel.getChatRoomMessages(id) chatViewModel.loadChatRoomMessages(id) { initialMessages ->
messages.clear() messages.clear()
messages.addAll(initialMessages) messages.addAll(initialMessages)
// Stop loading spinner // Stop loading spinner
loading = false loading = false
}
// Get msg relays for each member // Get msg relays for each member
chatViewModel.chatRoomConnect(id) chatViewModel.chatRoomConnect(id)

View File

@@ -54,6 +54,7 @@ import coil3.compose.AsyncImage
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_plus import coop.composeapp.generated.resources.ic_plus
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -68,8 +69,10 @@ fun ProfileEditor(
initialName: String = "", initialName: String = "",
initialBio: String = "", initialBio: String = "",
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL) initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
isBusy: Boolean = false,
onBack: () -> Unit, onBack: () -> Unit,
onConfirm: suspend (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit,
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
@@ -79,7 +82,6 @@ fun ProfileEditor(
var name by remember(initialName) { mutableStateOf(initialName) } var name by remember(initialName) { mutableStateOf(initialName) }
var bio by remember(initialBio) { mutableStateOf(initialBio) } var bio by remember(initialBio) { mutableStateOf(initialBio) }
var picture by remember(initialPicture) { mutableStateOf(initialPicture) } var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
var isBusy by remember { mutableStateOf(false) }
val hasPicture = remember(picture) { val hasPicture = remember(picture) {
when (picture) { when (picture) {
@@ -267,9 +269,8 @@ fun ProfileEditor(
.size(ButtonDefaults.MediumContainerHeight), .size(ButtonDefaults.MediumContainerHeight),
onClick = { onClick = {
scope.launch { scope.launch {
isBusy = true
try { try {
val bytes = withContext(Dispatchers.IO) { val bytes = withContext(ioDispatcher) {
(picture as? Uri)?.let { (picture as? Uri)?.let {
context.contentResolver.openInputStream(it)?.readBytes() context.contentResolver.openInputStream(it)?.readBytes()
} }
@@ -280,7 +281,6 @@ fun ProfileEditor(
} catch (e: Exception) { } catch (e: Exception) {
snackbarHostState.showSnackbar(e.message ?: "Error") snackbarHostState.showSnackbar(e.message ?: "Error")
} }
isBusy = false
} }
}, },
enabled = name.isNotBlank() && !isBusy enabled = name.isNotBlank() && !isBusy

View File

@@ -41,8 +41,7 @@ data class Room(
} }
companion object { companion object {
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room { fun new(rumor: UnsignedEvent, userPubkey: PublicKey, id: Long = rumor.roomId()): Room {
val id = rumor.roomId()
val createdAt = rumor.createdAt() val createdAt = rumor.createdAt()
val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content() val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content()

View File

@@ -1,5 +1,6 @@
package su.reya.coop.nostr package su.reya.coop.nostr
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -119,7 +120,9 @@ class MessageManager(private val nostr: Nostr) {
setCachedRumor(event.id(), unsignedEvent) setCachedRumor(event.id(), unsignedEvent)
return unsignedEvent return unsignedEvent
} catch (e: Throwable) { } catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}") println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}")
return null return null
} }
@@ -131,7 +134,9 @@ class MessageManager(private val nostr: Nostr) {
val event = client?.database()?.query(filter)?.first() val event = client?.database()?.query(filter)?.first()
return event?.content()?.let { UnsignedEvent.fromJson(it).ensureId() } return event?.content()?.let { UnsignedEvent.fromJson(it).ensureId() }
} catch (e: Throwable) { } catch (e: CancellationException) {
throw e
} catch (e: Exception) {
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e) throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
} }
} }
@@ -155,7 +160,9 @@ class MessageManager(private val nostr: Nostr) {
.finalizeAsync(Keys.generate()) .finalizeAsync(Keys.generate())
client?.database()?.saveEvent(event) client?.database()?.saveEvent(event)
} catch (e: Throwable) { } catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Failed to set cached rumor: ${e.message}") println("Failed to set cached rumor: ${e.message}")
} }
} }
@@ -170,36 +177,35 @@ class MessageManager(private val nostr: Nostr) {
// Get all DM events // Get all DM events
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm")) val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
val events = client?.database()?.query(filter) val events = client?.database()?.query(filter)?.toVec() ?: return null
// Collect rooms // Collect rooms
val roomsMap: MutableMap<Long, Room> = mutableMapOf() val roomsMap: MutableMap<Long, Room> = mutableMapOf()
events events
?.toVec() .map { UnsignedEvent.fromJson(it.content()) }
?.map { UnsignedEvent.fromJson(it.content()) } .filter { it.tags().publicKeys().isNotEmpty() }
?.filter { it.tags().publicKeys().isNotEmpty() } .forEach { rumor ->
?.forEach { event -> val id = rumor.roomId()
val newRoom = Room.new(rumor = event, userPubkey = userPubkey) val isFromMe = rumor.author() == userPubkey
val existingRoom = roomsMap[newRoom.id] val existing = roomsMap[id]
val createdAt = rumor.createdAt()
// Check if the room already exists // If the room is new or the current rumor is newer than the existing one
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) { if (existing == null || createdAt.asSecs() > existing.createdAt.asSecs()) {
val rTag = SingleLetterTag.lowercase(Alphabet.R) // A room is "Ongoing" if it was already marked as such or if the current rumor is from the user
val filter = Filter().kind(kind).pubkey(userPubkey) val isOngoing = (existing?.kind == RoomKind.Ongoing) || isFromMe
.customTag(rTag, newRoom.id.toString()) val room = Room.new(rumor = rumor, userPubkey = userPubkey, id = id)
roomsMap[id] = if (isOngoing) room.copy(kind = RoomKind.Ongoing) else room
// Determine if it's an ongoing room } else if (isFromMe && existing.kind != RoomKind.Ongoing) {
val isOngoing = // If it's an older rumor but sent by the user, mark the room as Ongoing
client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false roomsMap[id] = existing.copy(kind = RoomKind.Ongoing)
// Append room to map
roomsMap[newRoom.id] =
if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom
} }
} }
return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet() return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to get chat rooms: ${e.message}") println("Failed to get chat rooms: ${e.message}")
return null return null

View File

@@ -1,12 +1,15 @@
package su.reya.coop.nostr package su.reya.coop.nostr
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.AsyncNostrSigner
@@ -30,6 +33,7 @@ import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import rust.nostr.sdk.initLogger import rust.nostr.sdk.initLogger
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
object NostrManager { object NostrManager {
val instance = Nostr() val instance = Nostr()
@@ -47,7 +51,9 @@ object NostrManager {
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
} }
class Nostr { class Nostr(
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
var client: Client? = null var client: Client? = null
private set private set
var signer: UniversalSigner = UniversalSigner(Keys.generate()) var signer: UniversalSigner = UniversalSigner(Keys.generate())
@@ -59,17 +65,18 @@ class Nostr {
private val isInitialized = MutableStateFlow(false) private val isInitialized = MutableStateFlow(false)
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100) private val _newEvents = MutableSharedFlow<UnsignedEvent>(
replay = 0,
extraBufferCapacity = 100,
onBufferOverflow = BufferOverflow.SUSPEND
)
val newEvents = _newEvents.asSharedFlow() val newEvents = _newEvents.asSharedFlow()
suspend fun emitNewEvent(event: UnsignedEvent) { fun emitNewEvent(event: UnsignedEvent) {
_newEvents.emit(event) _newEvents.tryEmit(event)
} }
suspend fun init( suspend fun init(dbPath: String, logLevel: LogLevel = LogLevel.WARN) {
dbPath: String,
logLevel: LogLevel = LogLevel.WARN
) {
try { try {
if (isInitialized.value) return if (isInitialized.value) return
@@ -105,7 +112,8 @@ class Nostr {
} }
suspend fun waitUntilInitialized() { suspend fun waitUntilInitialized() {
isInitialized.first { it } withTimeoutOrNull(30.seconds) { isInitialized.first { it } }
?: throw IllegalStateException("Nostr initialization timed out")
} }
suspend fun connectBootstrapRelays() { suspend fun connectBootstrapRelays() {
@@ -159,7 +167,7 @@ class Nostr {
var processedCount = 0 var processedCount = 0
var eoseReceived = false var eoseReceived = false
launch(Dispatchers.Default) { launch(defaultDispatcher) {
for (event in giftWrapQueue) { for (event in giftWrapQueue) {
val rumor = messages.extractRumor(event) val rumor = messages.extractRumor(event)
processedCount++ processedCount++

View File

@@ -4,6 +4,7 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import rust.nostr.sdk.AckPolicy import rust.nostr.sdk.AckPolicy
@@ -33,10 +34,19 @@ class ProfileManager(private val nostr: Nostr) {
private val signer: UniversalSigner get() = nostr.signer private val signer: UniversalSigner get() = nostr.signer
private val _metadataUpdates = private val _metadataUpdates =
MutableSharedFlow<Pair<PublicKey, Metadata>>(extraBufferCapacity = 100) MutableSharedFlow<Pair<PublicKey, Metadata>>(
replay = 0,
extraBufferCapacity = 100,
onBufferOverflow = BufferOverflow.SUSPEND
)
val metadataUpdates = _metadataUpdates.asSharedFlow() val metadataUpdates = _metadataUpdates.asSharedFlow()
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100) private val _contactListUpdates =
MutableSharedFlow<List<PublicKey>>(
replay = 0,
extraBufferCapacity = 100,
onBufferOverflow = BufferOverflow.SUSPEND
)
val contactListUpdates = _contactListUpdates.asSharedFlow() val contactListUpdates = _contactListUpdates.asSharedFlow()
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) { suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) {
@@ -138,19 +148,24 @@ class ProfileManager(private val nostr: Nostr) {
bio: String? = null, bio: String? = null,
picture: String? = null picture: String? = null
): Metadata { ): Metadata {
val currentUser =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
try { try {
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord() val currentUser =
val newRecord = record.copy( signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
displayName = name ?: record.displayName,
about = bio ?: record.about,
picture = picture ?: record.picture
)
val newMetadata = Metadata.fromRecord(newRecord)
val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
// Get the latest metadata event
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
// Build a new metadata based on old records
val newMetadata = Metadata.fromRecord(
record.copy(
displayName = name ?: record.displayName,
about = bio ?: record.about,
picture = picture ?: record.picture
)
)
// Send the new metadata event
val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
client?.sendEvent( client?.sendEvent(
event = event, event = event,
target = SendEventTarget.broadcast(), target = SendEventTarget.broadcast(),

View File

@@ -104,6 +104,32 @@ class RelayManager(private val nostr: Nostr) {
return msgRelayList return msgRelayList
} }
suspend fun getRelayList(publicKey: PublicKey): Map<RelayUrl, RelayMetadata?> {
try {
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
val filter = Filter().kind(kind).author(publicKey).limit(1u)
val events = client?.database()?.query(filter)
return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap())
} catch (e: Exception) {
throw IllegalStateException("Failed to get relay list: ${e.message}", e)
}
}
suspend fun setRelaylist(relays: Map<RelayUrl, RelayMetadata?>) {
try {
val event = EventBuilder.relayList(relays).finalizeAsync(signer)
client?.sendEvent(
event = event,
target = SendEventTarget.broadcast(),
ackPolicy = AckPolicy.none(),
)
} catch (e: Exception) {
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
}
}
suspend fun setMsgRelays(urls: List<RelayUrl>) { suspend fun setMsgRelays(urls: List<RelayUrl>) {
try { try {
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer) val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
@@ -154,30 +180,4 @@ class RelayManager(private val nostr: Nostr) {
throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e) throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e)
} }
} }
suspend fun getRelayList(publicKey: PublicKey): Map<RelayUrl, RelayMetadata?> {
try {
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
val filter = Filter().kind(kind).author(publicKey).limit(1u)
val events = client?.database()?.query(filter)
return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap())
} catch (e: Exception) {
throw IllegalStateException("Failed to get relay list: ${e.message}", e)
}
}
suspend fun setRelaylist(relays: Map<RelayUrl, RelayMetadata?>) {
try {
val event = EventBuilder.relayList(relays).finalizeAsync(signer)
client?.sendEvent(
event = event,
target = SendEventTarget.broadcast(),
ackPolicy = AckPolicy.none(),
)
} catch (e: Exception) {
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
}
}
} }

View File

@@ -29,16 +29,14 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
/** /**
* Switch to a new signer. * Switch to a new signer.
*/ */
suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock { suspend fun switch(newSigner: AsyncNostrSigner) {
val pubkey = try { val pubkey = withTimeoutOrNull(20.seconds) { newSigner.getPublicKeyAsync() }
withTimeoutOrNull(20.seconds) { ?: throw IllegalStateException("Failed to get public key from signer")
newSigner.getPublicKeyAsync()
} mutex.withLock {
} catch (e: Exception) { signer = newSigner
throw IllegalStateException("Failed to get public key from signer", e) _publicKeyFlow.value = pubkey
} }
signer = newSigner
_publicKeyFlow.value = pubkey
} }
override suspend fun getPublicKeyAsync(): PublicKey? { override suspend fun getPublicKeyAsync(): PublicKey? {

View File

@@ -1,13 +0,0 @@
package su.reya.coop.repository
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
object ErrorRepository {
private val _errors = Channel<String>(Channel.BUFFERED)
val errors = _errors.receiveAsFlow()
fun showError(message: String) {
_errors.trySend(message)
}
}

View File

@@ -3,6 +3,7 @@ package su.reya.coop.repository
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.AsyncNostrSigner
import su.reya.coop.blossom.BlossomClient import su.reya.coop.blossom.BlossomClient
@@ -31,6 +32,8 @@ class MediaRepository {
signer = signer, signer = signer,
) )
descriptor?.url descriptor?.url
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
println("Upload failed: ${e.message}") println("Upload failed: ${e.message}")
null null

View File

@@ -1,225 +0,0 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.EncryptedSecretKey
import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnect
import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey
import su.reya.coop.AppStorage
import su.reya.coop.nostr.ExternalSignerHandler
import su.reya.coop.nostr.ExternalSignerProxy
import su.reya.coop.nostr.Nostr
import su.reya.coop.nostr.SignerPermissions
import su.reya.coop.repository.MediaRepository
import kotlin.time.Duration.Companion.seconds
data class AuthState(
val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false,
)
class AuthViewModel(
private val nostr: Nostr,
private val storage: AppStorage,
private val externalSignerHandler: ExternalSignerHandler? = null,
) : BaseViewModel() {
private val mediaRepository = MediaRepository()
companion object {
private const val KEY_USER_SIGNER = "user_signer"
private const val KEY_APP_KEYS = "app_keys"
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
}
private val _state = MutableStateFlow(AuthState())
val state = _state.asStateFlow()
init {
// Check if the notification banner has been dismissed
checkNotificationBannerDismissedStatus()
// Check local stored secret (secret key or bunker)
login()
}
private fun checkNotificationBannerDismissedStatus() {
viewModelScope.launch {
val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
}
}
private fun login() {
viewModelScope.launch {
try {
val secret = withTimeoutOrNull(5.seconds) {
storage.getSecret(KEY_USER_SIGNER)
}
if (secret == null) {
_state.update { it.copy(signerRequired = true) }
return@launch
}
runCatching {
val (signer, _) = createSigner(secret)
nostr.setSigner(signer)
}.onSuccess {
_state.update { it.copy(signerRequired = false) }
}.onFailure { e ->
showError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
} catch (e: Exception) {
showError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
}
}
fun logout(onLogout: () -> Unit = {}) {
viewModelScope.launch {
try {
// Reset the nostr signer and prune the database
nostr.signer.switch(Keys.generate())
nostr.prune()
} catch (e: Exception) {
showError("Logout encountered an error: ${e.message}")
} finally {
// Clear credentials from persistent storage
storage.clear(KEY_USER_SIGNER)
storage.clear(KEY_BANNER_DISMISSED)
// Call cleanup callback (e.g. to reset other ViewModels)
onLogout()
// Reset local states
_state.update { it.copy(signerRequired = true) }
}
}
}
fun dismissNotificationBanner() {
viewModelScope.launch {
storage.set(KEY_BANNER_DISMISSED, "true")
_state.update { it.copy(isNotificationBannerDismissed = true) }
}
}
private suspend fun getOrInitAppKeys(): Keys {
val secret = storage.getSecret(KEY_APP_KEYS)
// If app keys are already stored, use them
if (secret != null) return Keys.parse(secret)
// Generate new app keys and save to the secret storage
val keys = Keys.generate()
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
return keys
}
private suspend fun createSigner(
secret: String,
password: String? = null
): Pair<AsyncNostrSigner, String?> {
return when {
secret.startsWith("nsec1") -> Keys.parse(secret) to null
secret.startsWith("ncryptsec1") -> {
if (password == null) throw IllegalArgumentException("Password is required")
val enc = EncryptedSecretKey.fromBech32(secret)
val secret = enc.decrypt(password)
val keys = Keys(secret)
keys to keys.secretKey().toBech32()
}
secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = 50.seconds
NostrConnect(uri = bunker, appKeys, timeout, null) to null
}
secret.startsWith("nip55://") -> {
val handler = externalSignerHandler
?: throw IllegalStateException("External signer not available on this platform")
// Format: nip55://packageName/hexPubkey
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
val packageName = parts[0]
val pubkey = PublicKey.parse(parts[1])
handler.setPackageName(packageName)
ExternalSignerProxy(handler, pubkey) to null
}
else -> throw IllegalArgumentException("Invalid secret format")
}
}
suspend fun importIdentity(secret: String, password: String? = null) {
val (signer, decryptedSecret) = createSigner(secret, password)
// Update signer
nostr.setSigner(signer)
// Persist the secret in the secret storage
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
// Update local states
_state.update { it.copy(signerRequired = false) }
}
suspend fun connectExternalSigner() {
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
val permissions = SignerPermissions.toJson(
listOf(
SignerPermissions.signEvent(0),
SignerPermissions.signEvent(3),
SignerPermissions.signEvent(10000),
SignerPermissions.signEvent(10050),
SignerPermissions.signEvent(10063),
SignerPermissions.signEvent(22242),
SignerPermissions.signEvent(30030),
SignerPermissions.signEvent(30315),
SignerPermissions.nip44Encrypt(),
SignerPermissions.nip44Decrypt(),
)
)
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
val signer = ExternalSignerProxy(handler, result.pubkey)
// Update signer
nostr.setSigner(signer)
// Store the signer in the secret storage
storage.setSecret(KEY_USER_SIGNER, "nip55://${result.packageName}/${result.pubkey.toHex()}")
// Update local states
_state.update { it.copy(signerRequired = false) }
}
fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true
}
suspend fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
}
// Create identity
nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)
// Persist the secret in the secret storage
storage.setSecret(KEY_USER_SIGNER, secret)
// Update local states
_state.update { it.copy(signerRequired = false) }
}
}

View File

@@ -1,10 +1,19 @@
package su.reya.coop.viewmodel package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import su.reya.coop.repository.ErrorRepository import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
abstract class BaseViewModel : ViewModel() { abstract class BaseViewModel : ViewModel() {
private val _errorEvents = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 10,
onBufferOverflow = BufferOverflow.SUSPEND
)
val errorEvents = _errorEvents.asSharedFlow()
protected fun showError(message: String) { protected fun showError(message: String) {
ErrorRepository.showError(message) _errorEvents.tryEmit(message)
} }
} }

View File

@@ -1,11 +1,12 @@
package su.reya.coop.viewmodel package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -15,7 +16,6 @@ import rust.nostr.sdk.EventId
import rust.nostr.sdk.Kind import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Tag import rust.nostr.sdk.Tag
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.Room import su.reya.coop.Room
@@ -24,34 +24,32 @@ import su.reya.coop.repository.MediaRepository
import su.reya.coop.roomId import su.reya.coop.roomId
data class ChatState( data class ChatState(
val rooms: Set<Room> = emptySet(), val rooms: Map<Long, Room> = emptyMap(),
val isSyncing: Boolean = false,
val isPartialProcessedGiftWrap: Boolean = false, val isPartialProcessedGiftWrap: Boolean = false,
) )
class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { class ChatViewModel(
private val mediaRepository = MediaRepository() private val nostr: Nostr,
private val mediaRepository: MediaRepository,
) : BaseViewModel() {
private val _state = MutableStateFlow(ChatState()) private val _state = MutableStateFlow(ChatState())
val state = combine( val state = _state.stateIn(
_state,
nostr.messages.messageSyncState
) { local, state -> local.copy(isSyncing = state.isSyncing) }.stateIn(
viewModelScope, viewModelScope,
SharingStarted.WhileSubscribed(5000), SharingStarted.WhileSubscribed(5000),
ChatState() ChatState()
) )
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100) private val _newEvents = MutableSharedFlow<UnsignedEvent>(
replay = 0,
extraBufferCapacity = 100,
onBufferOverflow = BufferOverflow.SUSPEND
)
val newEvents = _newEvents.asSharedFlow() val newEvents = _newEvents.asSharedFlow()
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>() val chatRooms = state.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } }
val sentReport = _sentReports.asSharedFlow() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val chatRooms = state.map { it.rooms } val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
val isSyncing = state.map { it.isSyncing }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap } val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap }
@@ -69,8 +67,8 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
_state.update { it.copy(isPartialProcessedGiftWrap = true) } _state.update { it.copy(isPartialProcessedGiftWrap = true) }
} }
// Refresh UI every 10 messages OR when sync is fully done // Refresh UI every 100 messages OR when sync is fully done
if (syncState.processedCount % 10 == 0 || !syncState.isSyncing) { if (syncState.processedCount % 100 == 0 || !syncState.isSyncing) {
refreshChatRooms() refreshChatRooms()
} }
} }
@@ -80,26 +78,19 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
launch { launch {
nostr.newEvents.collect { event -> nostr.newEvents.collect { event ->
val roomId = event.roomId() val roomId = event.roomId()
val existingRoom = _state.value.rooms.firstOrNull { it.id == roomId } val existingRoom = _state.value.rooms[roomId]
if (existingRoom == null) { if (existingRoom == null) {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
val newRoom = Room.new(event, currentUser) val newRoom = Room.new(event, currentUser)
_state.update { _state.update { it.copy(rooms = it.rooms + (newRoom.id to newRoom)) }
it.copy(
rooms = (it.rooms + newRoom).sortedDescending().toSet()
)
}
} else { } else {
updateRoomList(roomId, event) updateRoomList(roomId, event)
} }
_newEvents.emit(event) _newEvents.tryEmit(event)
} }
} }
// Initial load of rooms
refreshChatRooms()
} }
} }
@@ -120,7 +111,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
// Check if the room already exists // Check if the room already exists
val id = rumor.roomId() val id = rumor.roomId()
val existingRoom = _state.value.rooms.firstOrNull { it.id == id } val existingRoom = _state.value.rooms[id]
// If the room already exists, return its ID // If the room already exists, return its ID
if (existingRoom != null) { if (existingRoom != null) {
@@ -131,7 +122,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
val room = Room.new(rumor, currentUser) val room = Room.new(rumor, currentUser)
// Update the chat rooms state // Update the chat rooms state
_state.update { it.copy(rooms = (it.rooms + room).sortedDescending().toSet()) } _state.update { it.copy(rooms = it.rooms + (room.id to room)) }
return room.id return room.id
} catch (e: Exception) { } catch (e: Exception) {
@@ -140,34 +131,36 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
} }
fun getChatRoom(id: Long): Room? { fun getChatRoom(id: Long): Room? {
return _state.value.rooms.firstOrNull { it.id == id } return _state.value.rooms[id]
} }
suspend fun refreshChatRooms() { fun refreshChatRooms() {
try { viewModelScope.launch {
val rooms = nostr.messages.getChatRooms() ?: emptySet() try {
_state.update { currentState -> val rooms = nostr.messages.getChatRooms() ?: emptySet()
val merged = currentState.rooms.associateBy { it.id }.toMutableMap() _state.update { currentState ->
// Add or update rooms from the database val newMap = currentState.rooms.toMutableMap()
rooms.forEach { room -> rooms.forEach { room -> newMap[room.id] = room }
merged[room.id] = room currentState.copy(rooms = newMap)
} }
// Return as a sorted set to maintain UI consistency } catch (e: CancellationException) {
currentState.copy(rooms = merged.values.sortedDescending().toSet()) throw e
} catch (e: Exception) {
showError("Error: ${e.message}")
} }
} catch (e: Exception) {
showError("Error: ${e.message}")
} }
} }
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> { fun loadChatRoomMessages(roomId: Long, onResult: (List<UnsignedEvent>) -> Unit) {
try { viewModelScope.launch {
return nostr.messages.getChatRoomMessages(roomId) try {
} catch (e: Exception) { onResult(nostr.messages.getChatRoomMessages(roomId))
showError("Error: ${e.message}") } catch (e: CancellationException) {
throw e
} catch (e: Exception) {
showError("Error: ${e.message}")
}
} }
return emptyList()
} }
fun chatRoomConnect(roomId: Long) { fun chatRoomConnect(roomId: Long) {
@@ -186,6 +179,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) { fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
if (message.isEmpty()) { if (message.isEmpty()) {
showError("Message cannot be empty") showError("Message cannot be empty")
return
} }
viewModelScope.launch { viewModelScope.launch {
try { try {
@@ -197,7 +191,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
replies = replies, replies = replies,
onRumorCreated = { event -> onRumorCreated = { event ->
updateRoomList(roomId, event) updateRoomList(roomId, event)
viewModelScope.launch { _newEvents.emit(event) } viewModelScope.launch { _newEvents.tryEmit(event) }
}, },
) )
} catch (e: Exception) { } catch (e: Exception) {
@@ -206,7 +200,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
} }
} }
suspend fun sendFileMessage( fun sendFileMessage(
roomId: Long, roomId: Long,
file: ByteArray?, file: ByteArray?,
contentType: String? = "image/jpeg", contentType: String? = "image/jpeg",
@@ -214,11 +208,13 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
) { ) {
if (file == null) return if (file == null) return
try { viewModelScope.launch {
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType) try {
if (uri != null) sendMessage(roomId, uri, replies) val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
} catch (e: Exception) { if (uri != null) sendMessage(roomId, uri, replies)
throw IllegalArgumentException("Error: ${e.message}") } catch (e: Exception) {
showError("File upload failed: ${e.message}")
}
} }
} }
@@ -235,24 +231,19 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) { private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
_state.update { currentState -> _state.update { currentState ->
val updatedRooms = currentState.rooms.map { room -> val room = currentState.rooms[roomId] ?: return@update currentState
if (room.id == roomId) { val updatedRoom = room.copy(
room.copy( lastMessage = newMessage.content(),
lastMessage = newMessage.content(), createdAt = newMessage.createdAt()
createdAt = newMessage.createdAt() )
) currentState.copy(rooms = currentState.rooms + (roomId to updatedRoom))
} else {
room
}
}.sortedDescending().toSet()
currentState.copy(rooms = updatedRooms)
} }
} }
fun resetInternalState() { fun resetInternalState() {
_state.update { _state.update {
it.copy( it.copy(
rooms = emptySet(), rooms = emptyMap(),
isPartialProcessedGiftWrap = false, isPartialProcessedGiftWrap = false,
) )
} }

View File

@@ -1,94 +1,35 @@
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.awaitCancellation
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
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.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.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.RelayMetadata
import rust.nostr.sdk.RelayUrl
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
import kotlin.time.Duration.Companion.seconds
data class NostrAppState(
val isBusy: Boolean = false,
val isRelayListEmpty: Boolean = false,
)
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
private val mediaRepository = MediaRepository()
private val alwaysRunTasks = flow {
coroutineScope {
val observerJob = launch { runObserver() }
val batchingJob = launch { runMetadataBatching() }
try {
emit(Unit)
awaitCancellation()
} finally {
observerJob.cancel()
batchingJob.cancel()
}
}
}
private val _appState = MutableStateFlow(NostrAppState())
val appState: StateFlow<NostrAppState> =
combine(_appState, alwaysRunTasks) { state, _ -> state }.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = NostrAppState()
)
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>()
val isRelayListEmpty = appState.map { it.isRelayListEmpty }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
@OptIn(ExperimentalCoroutinesApi::class)
val currentUserProfile = nostr.signer.publicKeyFlow
.flatMapLatest { pubkey ->
if (pubkey != null) getMetadata(pubkey) else flowOf(null)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
init { init {
// Launch continuous background observers
viewModelScope.launch { runObserver() }
viewModelScope.launch { runMetadataBatching() }
// Automatically reconnect bootstrap relays // Automatically reconnect bootstrap relays
reconnect() reconnect()
// Observe the signer state and verify the relay list
observeSignerAndCheckRelays()
// Get all local stored metadata // Get all local stored metadata
getCacheMetadata() getCacheMetadata()
} }
@@ -101,13 +42,6 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
} }
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) ->
@@ -116,7 +50,7 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
} }
} }
private suspend fun runMetadataBatching() = coroutineScope { private suspend fun runMetadataBatching() {
// Wait until the client is ready // Wait until the client is ready
nostr.waitUntilInitialized() nostr.waitUntilInitialized()
@@ -158,49 +92,27 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
viewModelScope.launch { viewModelScope.launch {
// Wait until the client is ready // Wait until the client is ready
nostr.waitUntilInitialized() nostr.waitUntilInitialized()
val cache = nostr.profiles.getAllCacheMetadata()
nostr.profiles.getAllCacheMetadata().forEach { (pubkey, metadata) -> profilesMutex.withLock {
// Update the metadata state cache.forEach { (pubkey, metadata) ->
updateMetadata(pubkey, Profile(pubkey, metadata)) val profile = Profile(pubkey, metadata)
// Update seenPublicKeys to avoid duplicate requests profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
seenPublicKeys.add(pubkey) seenPublicKeys.add(pubkey)
}
} }
} }
} }
private fun observeSignerAndCheckRelays() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
// Wait until a signer is explicitly set (which updates publicKeyFlow)
val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first()
// Get all metadata for the current user
nostr.profiles.getUserMetadata()
// Small delay to ensure all relays are connected
delay(2.seconds)
// Check if the relay list is empty
val relays = nostr.relays.getMsgRelays(currentUser)
if (relays.isEmpty()) _appState.update { it.copy(isRelayListEmpty = true) }
}
}
private fun requestMetadata(pubkey: PublicKey) { private fun requestMetadata(pubkey: PublicKey) {
if (seenPublicKeys.add(pubkey)) { if (seenPublicKeys.add(pubkey)) {
viewModelScope.launch { metadataRequestChannel.trySend(pubkey)
metadataRequestChannel.send(pubkey)
}
} }
} }
private fun updateMetadata(pubkey: PublicKey, profile: Profile) { private suspend fun updateMetadata(pubkey: PublicKey, profile: Profile) {
viewModelScope.launch { profilesMutex.withLock {
profilesMutex.withLock { profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
}
} }
} }
@@ -211,234 +123,4 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
return flow.asStateFlow() return flow.asStateFlow()
} }
fun resetInternalState() {
_contactList.value = emptySet()
_appState.update {
it.copy(
isRelayListEmpty = false,
)
}
}
fun dismissRelayWarning() {
_appState.update { it.copy(isRelayListEmpty = false) }
}
suspend fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) {
_appState.update { it.copy(isBusy = true) }
try {
val avatarUrl =
picture?.let {
mediaRepository.blossomUpload(
nostr.signer.get(),
it,
contentType ?: "image/jpeg"
)
}
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
// Update the metadata state after successfully published
updateMetadata(currentUser, Profile(currentUser, newMetadata))
// Update local state
_appState.update { it.copy(isBusy = false) }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun refetchMsgRelays() {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
val relays = nostr.relays.fetchMsgRelays(currentUser)
if (relays.isNotEmpty()) dismissRelayWarning()
}
suspend fun useDefaultMsgRelayList() {
try {
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
nostr.relays.setMsgRelays(defaultRelays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun currentUserRelayList(): Map<RelayUrl, RelayMetadata?> {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getRelayList(currentUser)
} catch (e: Exception) {
showError("Error: ${e.message}")
return emptyMap()
}
}
suspend fun addInboxRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayList().toMutableMap()
relays[relayUrl] = RelayMetadata.WRITE
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun addOutboxRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayList().toMutableMap()
relays[relayUrl] = RelayMetadata.READ
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun removeRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayList().toMutableMap()
relays.remove(relayUrl)
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun currentUserMsgRelayList(): List<RelayUrl> {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getMsgRelays(currentUser)
} catch (e: Exception) {
showError("Error: ${e.message}")
return emptyList()
}
}
suspend fun addMsgRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayList().toMutableSet()
relays.add(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun removeMsgRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayList().toMutableSet()
relays.remove(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
private suspend fun newContact(publicKey: PublicKey) {
if (publicKey in contactList.value) return
try {
val updated = contactList.value + publicKey
// Publish new event
nostr.profiles.setContactList(updated.toList())
// Optimistic local update
_contactList.update { it + publicKey }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun addContact(address: String): Boolean {
val pubkey = try {
if (address.contains("@")) {
nostr.profiles.searchByAddress(address)
} else {
PublicKey.parse(address)
}
} catch (e: Exception) {
showError("Invalid contact address: ${e.message}")
return false
}
return run {
newContact(pubkey)
true
}
}
fun removeContact(publicKey: PublicKey) {
viewModelScope.launch {
if (publicKey !in contactList.value) return@launch
try {
val updated = contactList.value - publicKey
// Publish new event
nostr.profiles.setContactList(updated.toList())
// Optimistic local update
_contactList.update { it - publicKey }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
suspend fun searchByAddress(query: String): PublicKey? {
try {
return nostr.profiles.searchByAddress(query)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
return null
}
suspend fun searchByNostr(query: String): List<PublicKey> {
try {
return nostr.profiles.searchByNostr(query)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
return emptyList()
}
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
return try {
nostr.profiles.verifyActivity(pubkey)
} catch (e: Exception) {
showError("Error: ${e.message}")
null
}
}
suspend fun verifyContact(pubkey: PublicKey): Boolean {
return try {
nostr.profiles.verifyContact(pubkey)
} catch (e: Exception) {
showError("Error: ${e.message}")
false
}
}
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
return try {
nostr.profiles.mutualContacts(pubkey)
} catch (e: Exception) {
showError("Error: ${e.message}")
setOf()
}
}
} }

View File

@@ -0,0 +1,252 @@
package su.reya.coop.viewmodel.account
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.EncryptedSecretKey
import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnect
import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey
import su.reya.coop.AppStorage
import su.reya.coop.nostr.ExternalSignerHandler
import su.reya.coop.nostr.ExternalSignerProxy
import su.reya.coop.nostr.Nostr
import su.reya.coop.nostr.SignerPermissions
import su.reya.coop.repository.MediaRepository
import kotlin.time.Duration.Companion.seconds
data class AccountState(
val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false,
val isImporting: Boolean = false,
val importError: String? = null,
)
class AccountAuthDelegate(
private val nostr: Nostr,
private val storage: AppStorage,
private val mediaRepository: MediaRepository,
private val externalSignerHandler: ExternalSignerHandler? = null,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
private val onError: (String) -> Unit,
private val onSignerReady: () -> Unit,
private val scope: CoroutineScope,
) {
companion object {
private const val KEY_USER_SIGNER = "user_signer"
private const val KEY_APP_KEYS = "app_keys"
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
}
private val _state = MutableStateFlow(AccountState())
val state: StateFlow<AccountState> = _state.asStateFlow()
fun init() {
checkNotificationBannerDismissedStatus()
login()
}
private fun checkNotificationBannerDismissedStatus() {
scope.launch {
val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
}
}
private fun login() {
scope.launch {
try {
val secret = withTimeoutOrNull(5.seconds) {
storage.getSecret(KEY_USER_SIGNER)
}
if (secret == null) {
_state.update { it.copy(signerRequired = true) }
return@launch
}
runCatching {
val (signer, _) = createSigner(secret)
nostr.setSigner(signer)
}.onSuccess {
_state.update { it.copy(signerRequired = false) }
onSignerReady()
}.onFailure { e ->
onError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
} catch (e: Exception) {
onError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
}
}
fun logout(onLogout: () -> Unit = {}) {
scope.launch {
try {
nostr.signer.switch(Keys.generate())
nostr.prune()
} catch (e: Exception) {
onError("Logout encountered an error: ${e.message}")
} finally {
storage.clear(KEY_USER_SIGNER)
storage.clear(KEY_BANNER_DISMISSED)
onLogout()
_state.update { it.copy(signerRequired = true) }
}
}
}
fun dismissNotificationBanner() {
scope.launch {
storage.set(KEY_BANNER_DISMISSED, "true")
_state.update { it.copy(isNotificationBannerDismissed = true) }
}
}
private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) {
val secret = storage.getSecret(KEY_APP_KEYS)
if (secret != null) return@withContext Keys.parse(secret)
val keys = Keys.generate()
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
keys
}
private suspend fun createSigner(
secret: String,
password: String? = null
): Pair<AsyncNostrSigner, String?> = withContext(defaultDispatcher) {
when {
secret.startsWith("nsec1") -> Keys.parse(secret) to null
secret.startsWith("ncryptsec1") -> {
if (password == null) throw IllegalArgumentException("Password is required")
val enc = EncryptedSecretKey.fromBech32(secret)
val decrypted = enc.decrypt(password)
val keys = Keys(decrypted)
keys to keys.secretKey().toBech32()
}
secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = 50.seconds
NostrConnect(uri = bunker, appKeys, timeout, null) to null
}
secret.startsWith("nip55://") -> {
val handler = externalSignerHandler ?: throw IllegalStateException("Not available")
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
val packageName = parts[0]
val pubkey = PublicKey.parse(parts[1])
handler.setPackageName(packageName)
ExternalSignerProxy(handler, pubkey) to null
}
else -> throw IllegalArgumentException("Invalid secret format")
}
}
fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true
}
fun importIdentity(secret: String, password: String? = null) {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val (signer, decryptedSecret) = createSigner(secret, password)
nostr.setSigner(signer)
onSignerReady()
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
onError("Import failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
fun connectExternalSigner() {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val handler =
externalSignerHandler ?: throw IllegalStateException("Signer not available")
val permissions = SignerPermissions.toJson(
listOf(
SignerPermissions.signEvent(0),
SignerPermissions.signEvent(3),
SignerPermissions.signEvent(10000),
SignerPermissions.signEvent(10050),
SignerPermissions.signEvent(10063),
SignerPermissions.signEvent(22242),
SignerPermissions.signEvent(30030),
SignerPermissions.signEvent(30315),
SignerPermissions.nip44Encrypt(),
SignerPermissions.nip44Decrypt(),
)
)
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
val signer = ExternalSignerProxy(handler, result.pubkey)
val uri = "nip55://${result.packageName}/${result.pubkey.toHex()}"
nostr.setSigner(signer)
onSignerReady()
storage.setSecret(KEY_USER_SIGNER, uri)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
onError("External signer connection failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
}
nostr.profiles.createIdentity(
keys = keys,
name = name,
bio = bio,
picture = avatarUrl
)
onSignerReady()
storage.setSecret(KEY_USER_SIGNER, secret)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
onError("Identity creation failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
}

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

@@ -0,0 +1,185 @@
package su.reya.coop.viewmodel.account
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import su.reya.coop.nostr.Nostr
import kotlin.time.Duration.Companion.seconds
class AccountRelayDelegate(
private val nostr: Nostr,
private val onError: (String) -> Unit,
private val scope: CoroutineScope,
) {
private val _isRelayListEmpty = MutableStateFlow(false)
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
private val _currentUserRelayList = MutableStateFlow<Map<RelayUrl, RelayMetadata?>>(emptyMap())
val currentUserRelayList = _currentUserRelayList.asStateFlow()
private val _currentUserMsgRelayList = MutableStateFlow<List<RelayUrl>>(emptyList())
val currentUserMsgRelayList = _currentUserMsgRelayList.asStateFlow()
fun reset() {
_isRelayListEmpty.value = false
}
@OptIn(ExperimentalCoroutinesApi::class)
fun checkRelayList() {
scope.launch {
val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first()
println("user: ${currentUser.toBech32()}")
// Small delay to ensure subscription is ready
delay(6.seconds)
val relays = nostr.relays.getMsgRelays(currentUser)
if (relays.isEmpty()) _isRelayListEmpty.value = true
}
}
fun dismissRelayWarning() {
_isRelayListEmpty.value = false
}
fun refetchMsgRelays() {
scope.launch {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch
val relays = nostr.relays.fetchMsgRelays(currentUser)
if (relays.isNotEmpty()) dismissRelayWarning()
}
}
fun useDefaultMsgRelayList() {
scope.launch {
try {
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
nostr.relays.setMsgRelays(defaultRelays)
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
fun loadCurrentUserRelayList() {
scope.launch {
try {
val currentUser =
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_currentUserRelayList.value = nostr.relays.getRelayList(currentUser)
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
private suspend fun currentUserRelayListInternal(): Map<RelayUrl, RelayMetadata?> {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getRelayList(currentUser)
} catch (e: Exception) {
onError("Error: ${e.message}")
return emptyMap()
}
}
fun addInboxRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.WRITE
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
fun addOutboxRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.READ
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
fun removeRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays.remove(relayUrl)
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
fun loadCurrentUserMsgRelayList() {
scope.launch {
try {
val currentUser =
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_currentUserMsgRelayList.value = nostr.relays.getMsgRelays(currentUser)
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
private suspend fun currentUserMsgRelayListInternal(): List<RelayUrl> {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getMsgRelays(currentUser)
} catch (e: Exception) {
onError("Error: ${e.message}")
return emptyList()
}
}
fun addMsgRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayListInternal().toMutableSet()
relays.add(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
fun removeMsgRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayListInternal().toMutableSet()
relays.remove(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
}

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