chore: improve performance #41

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

View File

@@ -51,6 +51,7 @@ import su.reya.coop.screens.UpdateProfileScreen
import su.reya.coop.viewmodel.AuthViewModel import su.reya.coop.viewmodel.AuthViewModel
import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.NostrViewModel import su.reya.coop.viewmodel.NostrViewModel
import su.reya.coop.viewmodel.RelayViewModel
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> { val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
error("No NostrViewModel provided") error("No NostrViewModel provided")
@@ -64,6 +65,10 @@ val LocalAuthViewModel = staticCompositionLocalOf<AuthViewModel> {
error("No AuthViewModel provided") error("No AuthViewModel provided")
} }
val LocalRelayViewModel = staticCompositionLocalOf<RelayViewModel> {
error("No RelayViewModel provided")
}
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> { val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided") error("No SnackbarHostState provided")
} }
@@ -80,6 +85,7 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
@Composable @Composable
fun App( fun App(
nostrViewModel: NostrViewModel, nostrViewModel: NostrViewModel,
relayViewModel: RelayViewModel,
chatViewModel: ChatViewModel, chatViewModel: ChatViewModel,
authViewModel: AuthViewModel, authViewModel: AuthViewModel,
) { ) {
@@ -133,6 +139,11 @@ fun App(
snackbarHostState.showSnackbar(message) snackbarHostState.showSnackbar(message)
} }
} }
launch {
relayViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
} }
LaunchedEffect(activity) { LaunchedEffect(activity) {
@@ -174,6 +185,7 @@ fun App(
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalNostrViewModel provides nostrViewModel, LocalNostrViewModel provides nostrViewModel,
LocalRelayViewModel provides relayViewModel,
LocalChatViewModel provides chatViewModel, LocalChatViewModel provides chatViewModel,
LocalAuthViewModel provides authViewModel, LocalAuthViewModel provides authViewModel,
LocalSnackbarHostState provides snackbarHostState, LocalSnackbarHostState provides snackbarHostState,

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

@@ -17,6 +17,7 @@ import su.reya.coop.repository.MediaRepository
import su.reya.coop.viewmodel.AuthViewModel import su.reya.coop.viewmodel.AuthViewModel
import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.NostrViewModel import su.reya.coop.viewmodel.NostrViewModel
import su.reya.coop.viewmodel.RelayViewModel
import kotlin.system.exitProcess import kotlin.system.exitProcess
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -29,6 +30,7 @@ class MainActivity : ComponentActivity() {
private val storage = AppStore(this@MainActivity) private val storage = AppStore(this@MainActivity)
private val mediaRepository = MediaRepository() private val mediaRepository = MediaRepository()
private val nostrViewModel = NostrViewModel(NostrManager.instance, mediaRepository) private val nostrViewModel = NostrViewModel(NostrManager.instance, mediaRepository)
private val relayViewModel = RelayViewModel(NostrManager.instance)
private val chatViewModel = ChatViewModel(NostrManager.instance, mediaRepository) private val chatViewModel = ChatViewModel(NostrManager.instance, mediaRepository)
private val androidSigner = private val androidSigner =
AndroidExternalSigner(this@MainActivity, externalSignerLauncher) AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
@@ -38,6 +40,7 @@ class MainActivity : ComponentActivity() {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when { return when {
modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel
modelClass.isAssignableFrom(RelayViewModel::class.java) -> relayViewModel
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel
modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel
else -> throw IllegalArgumentException("Unknown ViewModel class") else -> throw IllegalArgumentException("Unknown ViewModel class")
@@ -47,6 +50,7 @@ class MainActivity : ComponentActivity() {
} }
private val nostrViewModel: NostrViewModel by viewModels { factory } private val nostrViewModel: NostrViewModel by viewModels { factory }
private val relayViewModel: RelayViewModel by viewModels { factory }
private val chatViewModel: ChatViewModel by viewModels { factory } private val chatViewModel: ChatViewModel by viewModels { factory }
private val authViewModel: AuthViewModel by viewModels { factory } private val authViewModel: AuthViewModel by viewModels { factory }
@@ -93,6 +97,7 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
App( App(
nostrViewModel = nostrViewModel, nostrViewModel = nostrViewModel,
relayViewModel = relayViewModel,
chatViewModel = chatViewModel, chatViewModel = chatViewModel,
authViewModel = authViewModel, authViewModel = authViewModel,
) )

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

@@ -265,10 +265,8 @@ fun AddContactDialog(onDismissRequest: () -> Unit) {
}, },
actions = { actions = {
IconButton(onClick = { IconButton(onClick = {
scope.launch { nostrViewModel.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),

View File

@@ -93,6 +93,7 @@ import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalRelayViewModel
import su.reya.coop.LocalScanResult import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room import su.reya.coop.Room
@@ -114,6 +115,7 @@ fun HomeScreen() {
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current val chatViewModel = LocalChatViewModel.current
val authViewModel = LocalAuthViewModel.current val authViewModel = LocalAuthViewModel.current
val relayViewModel = LocalRelayViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(true) val sheetState = rememberModalBottomSheetState(true)
@@ -123,7 +125,7 @@ fun HomeScreen() {
val userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() val userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle() val isRelayListEmpty by relayViewModel.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()
@@ -319,11 +321,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 +469,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 = { relayViewModel.dismissRelayWarning() },
sheetState = sheetState, sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
) { ) {
@@ -573,15 +573,9 @@ fun HomeScreen() {
TextButton( TextButton(
enabled = !isBusy, enabled = !isBusy,
onClick = { onClick = {
scope.launch {
isBusy = true isBusy = true
try { relayViewModel.refetchMsgRelays()
nostrViewModel.refetchMsgRelays()
} catch (e: Exception) {
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
}
isBusy = false isBusy = false
}
}, },
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
@@ -595,10 +589,8 @@ fun HomeScreen() {
Button( Button(
enabled = !isBusy, enabled = !isBusy,
onClick = { onClick = {
scope.launch { relayViewModel.useDefaultMsgRelayList()
nostrViewModel.useDefaultMsgRelayList() scope.launch { sheetState.hide() }
sheetState.hide()
}
}, },
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)

View File

@@ -96,15 +96,17 @@ fun NewChatScreen() {
selectedReceivers.add(pubkey) selectedReceivers.add(pubkey)
} }
} else if (query.contains("@")) { } else if (query.contains("@")) {
val pubkey = nostrViewModel.searchByAddress(query) nostrViewModel.searchByAddress(query) { pubkey ->
if (pubkey != null) { if (pubkey != null) {
selectedReceivers.add(pubkey) selectedReceivers.add(pubkey)
} }
}
} else { } else {
val results = nostrViewModel.searchByNostr(query) nostrViewModel.searchByNostr(query) { results ->
searchResults.clear() searchResults.clear()
searchResults.addAll(results) searchResults.addAll(results)
} }
}
query = "" query = ""
} }

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.LocalRelayViewModel
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 relayViewModel = LocalRelayViewModel.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()) relayViewModel.loadCurrentUserRelayList()
msgRelayList.addAll(nostrViewModel.currentUserMsgRelayList()) relayViewModel.loadCurrentUserMsgRelayList()
}
val loadedRelayList by relayViewModel.currentUserRelayList.collectAsStateWithLifecycle()
val loadedMsgRelayList by relayViewModel.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 { relayToDelete = null
nostrViewModel.removeMsgRelay(relayToDelete!!) return@TextButton
}
relayViewModel.removeMsgRelay(relayToDelete!!)
msgRelayList.removeIf { it.toString() == relayToDelete } msgRelayList.removeIf { it.toString() == relayToDelete }
relayToDelete = null relayToDelete = null
} catch (e: Exception) {
snackbarHostState.showSnackbar("Failed to remove relay: ${e.message}")
}
}
} }
) { ) {
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 relayViewModel = LocalRelayViewModel.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -397,27 +413,25 @@ fun AddRelayDialog(
}, },
actions = { actions = {
IconButton(onClick = { IconButton(onClick = {
scope.launch {
if (!isError) { if (!isError) {
when (selected) { when (selected) {
"Messaging" -> { "Messaging" -> {
nostrViewModel.addMsgRelay(relayAddress) relayViewModel.addMsgRelay(relayAddress)
onMsgRelayAdded(relayAddress) onMsgRelayAdded(relayAddress)
} }
"Inbox" -> { "Inbox" -> {
nostrViewModel.addInboxRelay(relayAddress) relayViewModel.addInboxRelay(relayAddress)
onRelayAdded(relayAddress, RelayMetadata.WRITE) onRelayAdded(relayAddress, RelayMetadata.WRITE)
} }
"Outbox" -> { "Outbox" -> {
nostrViewModel.addOutboxRelay(relayAddress) relayViewModel.addOutboxRelay(relayAddress)
onRelayAdded(relayAddress, RelayMetadata.READ) onRelayAdded(relayAddress, RelayMetadata.READ)
} }
} }
onDismissRequest() onDismissRequest()
} }
}
}) { }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_check), painter = painterResource(Res.drawable.ic_check),

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

@@ -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,7 +28,6 @@ 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
@@ -46,7 +44,6 @@ 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()
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 +53,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
nostrViewModel.verifyContact(pubkey).let { isContact = it } nostrViewModel.verifyContact(pubkey) { isContact = it }
// Get mutual contacts // Get mutual contacts
nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it } nostrViewModel.mutualContacts(pubkey) { mutualContacts = it }
// Get the last activity // Get the last activity
nostrViewModel.verifyActivity(pubkey)?.let { lastActivity = it } nostrViewModel.verifyActivity(pubkey) { lastActivity = it }
}
} }
Column( Column(

View File

@@ -59,6 +59,7 @@ 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
@@ -77,7 +78,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
@@ -121,7 +126,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
val sendFile = { uri: Uri -> val sendFile = { uri: Uri ->
scope.launch { scope.launch {
// Read file on IO dispatcher // Read file on IO dispatcher
val file = withContext(Dispatchers.IO) { val file = withContext(coroutineDispatcher) {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() } context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
} }
@@ -149,12 +154,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
@@ -69,7 +70,8 @@ fun ProfileEditor(
initialBio: String = "", initialBio: String = "",
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL) initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
onBack: () -> Unit, onBack: () -> Unit,
onConfirm: (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
@@ -269,7 +271,7 @@ fun ProfileEditor(
scope.launch { scope.launch {
isBusy = true 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()
} }

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}")
} }
} }
@@ -197,6 +204,8 @@ class MessageManager(private val nostr: Nostr) {
} }
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,5 +1,6 @@
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.BufferOverflow
@@ -50,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())
@@ -164,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

@@ -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,10 +1,13 @@
package su.reya.coop.viewmodel package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
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
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.EncryptedSecretKey import rust.nostr.sdk.EncryptedSecretKey
@@ -32,6 +35,7 @@ class AuthViewModel(
private val storage: AppStorage, private val storage: AppStorage,
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
private val externalSignerHandler: ExternalSignerHandler? = null, private val externalSignerHandler: ExternalSignerHandler? = null,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : BaseViewModel() { ) : BaseViewModel() {
companion object { companion object {
private const val KEY_USER_SIGNER = "user_signer" private const val KEY_USER_SIGNER = "user_signer"
@@ -112,21 +116,21 @@ class AuthViewModel(
} }
} }
private suspend fun getOrInitAppKeys(): Keys { private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) {
val secret = storage.getSecret(KEY_APP_KEYS) val secret = storage.getSecret(KEY_APP_KEYS)
// If app keys are already stored, use them // If app keys are already stored, use them
if (secret != null) return Keys.parse(secret) if (secret != null) return@withContext Keys.parse(secret)
// Generate new app keys and save to the secret storage // Generate new app keys and save to the secret storage
val keys = Keys.generate() val keys = Keys.generate()
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32()) storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
return keys keys
} }
private suspend fun createSigner( private suspend fun createSigner(
secret: String, secret: String,
password: String? = null password: String? = null
): Pair<AsyncNostrSigner, String?> { ): Pair<AsyncNostrSigner, String?> = withContext(defaultDispatcher) {
return when { when {
secret.startsWith("nsec1") -> Keys.parse(secret) to null secret.startsWith("nsec1") -> Keys.parse(secret) to null
secret.startsWith("ncryptsec1") -> { secret.startsWith("ncryptsec1") -> {

View File

@@ -134,7 +134,8 @@ class ChatViewModel(
return _state.value.rooms[id] return _state.value.rooms[id]
} }
suspend fun refreshChatRooms() { fun refreshChatRooms() {
viewModelScope.launch {
try { try {
val rooms = nostr.messages.getChatRooms() ?: emptySet() val rooms = nostr.messages.getChatRooms() ?: emptySet()
_state.update { currentState -> _state.update { currentState ->
@@ -148,17 +149,18 @@ class ChatViewModel(
showError("Error: ${e.message}") showError("Error: ${e.message}")
} }
} }
}
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> { fun loadChatRoomMessages(roomId: Long, onResult: (List<UnsignedEvent>) -> Unit) {
viewModelScope.launch {
try { try {
return nostr.messages.getChatRoomMessages(roomId) onResult(nostr.messages.getChatRoomMessages(roomId))
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} }
}
return emptyList()
} }
fun chatRoomConnect(roomId: Long) { fun chatRoomConnect(roomId: Long) {

View File

@@ -4,7 +4,6 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -13,7 +12,6 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
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
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -21,19 +19,15 @@ 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 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 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( data class NostrAppState(
val isBusy: Boolean = false, val isBusy: Boolean = false,
val isRelayListEmpty: Boolean = false,
) )
class NostrViewModel( class NostrViewModel(
@@ -51,9 +45,6 @@ class NostrViewModel(
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) @OptIn(ExperimentalCoroutinesApi::class)
val currentUserProfile = nostr.signer.publicKeyFlow val currentUserProfile = nostr.signer.publicKeyFlow
.flatMapLatest { pubkey -> .flatMapLatest { pubkey ->
@@ -69,8 +60,8 @@ class NostrViewModel(
// Automatically reconnect bootstrap relays // Automatically reconnect bootstrap relays
reconnect() reconnect()
// Observe the signer state and verify the relay list // Fetch metadata for the current user
observeSignerAndCheckRelays() fetchUserMetadata()
// Get all local stored metadata // Get all local stored metadata
getCacheMetadata() getCacheMetadata()
@@ -153,7 +144,7 @@ class NostrViewModel(
} }
} }
private fun observeSignerAndCheckRelays() { private fun fetchUserMetadata() {
viewModelScope.launch { viewModelScope.launch {
// Wait until the client is ready // Wait until the client is ready
nostr.waitUntilInitialized() nostr.waitUntilInitialized()
@@ -163,13 +154,6 @@ class NostrViewModel(
// Get all metadata for the current user // Get all metadata for the current user
nostr.profiles.getUserMetadata() 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) }
} }
} }
@@ -194,15 +178,6 @@ class NostrViewModel(
fun resetInternalState() { fun resetInternalState() {
_contactList.value = emptySet() _contactList.value = emptySet()
_appState.update {
it.copy(
isRelayListEmpty = false,
)
}
}
fun dismissRelayWarning() {
_appState.update { it.copy(isRelayListEmpty = false) }
} }
fun updateProfile( fun updateProfile(
@@ -238,105 +213,10 @@ class NostrViewModel(
} }
} }
suspend fun refetchMsgRelays() { private fun newContact(publicKey: PublicKey) {
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 if (publicKey in contactList.value) return
viewModelScope.launch {
try { try {
val updated = contactList.value + publicKey val updated = contactList.value + publicKey
// Publish new event // Publish new event
@@ -347,8 +227,10 @@ class NostrViewModel(
showError("Error: ${e.message}") showError("Error: ${e.message}")
} }
} }
}
suspend fun addContact(address: String): Boolean { fun addContact(address: String) {
viewModelScope.launch {
val pubkey = try { val pubkey = try {
if (address.contains("@")) { if (address.contains("@")) {
nostr.profiles.searchByAddress(address) nostr.profiles.searchByAddress(address)
@@ -357,12 +239,10 @@ class NostrViewModel(
} }
} catch (e: Exception) { } catch (e: Exception) {
showError("Invalid contact address: ${e.message}") showError("Invalid contact address: ${e.message}")
return false return@launch
} }
return run {
newContact(pubkey) newContact(pubkey)
true
} }
} }
@@ -382,48 +262,58 @@ class NostrViewModel(
} }
} }
suspend fun searchByAddress(query: String): PublicKey? { fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) {
viewModelScope.launch {
try { try {
return nostr.profiles.searchByAddress(query) onResult(nostr.profiles.searchByAddress(query))
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
onResult(null)
}
} }
return null
} }
suspend fun searchByNostr(query: String): List<PublicKey> { fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) {
viewModelScope.launch {
try { try {
return nostr.profiles.searchByNostr(query) onResult(nostr.profiles.searchByNostr(query))
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
onResult(emptyList())
} }
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 { fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) {
return try { viewModelScope.launch {
nostr.profiles.verifyContact(pubkey) try {
onResult(nostr.profiles.verifyActivity(pubkey))
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
false onResult(null)
}
} }
} }
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> { fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) {
return try { viewModelScope.launch {
nostr.profiles.mutualContacts(pubkey) try {
onResult(nostr.profiles.verifyContact(pubkey))
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
setOf() onResult(false)
}
}
}
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) {
viewModelScope.launch {
try {
onResult(nostr.profiles.mutualContacts(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(emptySet())
}
} }
} }
} }

View File

@@ -0,0 +1,188 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
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 RelayViewModel(
private val nostr: Nostr,
) : BaseViewModel() {
private val _isRelayListEmpty = MutableStateFlow(false)
val isRelayListEmpty: StateFlow<Boolean> = _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()
init {
checkRelayList()
}
private fun checkRelayList() {
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()
// 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()) _isRelayListEmpty.value = true
}
}
fun dismissRelayWarning() {
_isRelayListEmpty.value = false
}
fun resetInternalState() {
_isRelayListEmpty.value = false
}
fun refetchMsgRelays() {
viewModelScope.launch {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch
val relays = nostr.relays.fetchMsgRelays(currentUser)
if (relays.isNotEmpty()) dismissRelayWarning()
}
}
fun useDefaultMsgRelayList() {
viewModelScope.launch {
try {
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
nostr.relays.setMsgRelays(defaultRelays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun loadCurrentUserRelayList() {
viewModelScope.launch {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_currentUserRelayList.value = nostr.relays.getRelayList(currentUser)
} catch (e: Exception) {
showError("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) {
showError("Error: ${e.message}")
return emptyMap()
}
}
fun addInboxRelay(relay: String) {
viewModelScope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.WRITE
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun addOutboxRelay(relay: String) {
viewModelScope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.READ
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun removeRelay(relay: String) {
viewModelScope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays.remove(relayUrl)
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun loadCurrentUserMsgRelayList() {
viewModelScope.launch {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_currentUserMsgRelayList.value = nostr.relays.getMsgRelays(currentUser)
} catch (e: Exception) {
showError("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) {
showError("Error: ${e.message}")
return emptyList()
}
}
fun addMsgRelay(relay: String) {
viewModelScope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayListInternal().toMutableSet()
relays.add(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun removeMsgRelay(relay: String) {
viewModelScope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayListInternal().toMutableSet()
relays.remove(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
}