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

View File

@@ -4,12 +4,15 @@ import android.content.Intent
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class ExternalSignerLauncher {
class ExternalSignerLauncher(
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
) {
private var launcher: ActivityResultLauncher<Intent>? = null
private var pendingResult: CompletableDeferred<ActivityResult>? = null
private val mutex = Mutex()
@@ -19,7 +22,7 @@ class ExternalSignerLauncher {
}
suspend fun launch(intent: Intent): ActivityResult = mutex.withLock {
withContext(Dispatchers.Main) {
withContext(mainDispatcher) {
val deferred = CompletableDeferred<ActivityResult>()
pendingResult = deferred
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.ChatViewModel
import su.reya.coop.viewmodel.NostrViewModel
import su.reya.coop.viewmodel.RelayViewModel
import kotlin.system.exitProcess
class MainActivity : ComponentActivity() {
@@ -29,6 +30,7 @@ class MainActivity : ComponentActivity() {
private val storage = AppStore(this@MainActivity)
private val mediaRepository = MediaRepository()
private val nostrViewModel = NostrViewModel(NostrManager.instance, mediaRepository)
private val relayViewModel = RelayViewModel(NostrManager.instance)
private val chatViewModel = ChatViewModel(NostrManager.instance, mediaRepository)
private val androidSigner =
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
@@ -38,6 +40,7 @@ class MainActivity : ComponentActivity() {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when {
modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel
modelClass.isAssignableFrom(RelayViewModel::class.java) -> relayViewModel
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel
modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel
else -> throw IllegalArgumentException("Unknown ViewModel class")
@@ -47,6 +50,7 @@ class MainActivity : ComponentActivity() {
}
private val nostrViewModel: NostrViewModel by viewModels { factory }
private val relayViewModel: RelayViewModel by viewModels { factory }
private val chatViewModel: ChatViewModel by viewModels { factory }
private val authViewModel: AuthViewModel by viewModels { factory }
@@ -93,6 +97,7 @@ class MainActivity : ComponentActivity() {
setContent {
App(
nostrViewModel = nostrViewModel,
relayViewModel = relayViewModel,
chatViewModel = chatViewModel,
authViewModel = authViewModel,
)

View File

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

View File

@@ -265,10 +265,8 @@ fun AddContactDialog(onDismissRequest: () -> Unit) {
},
actions = {
IconButton(onClick = {
scope.launch {
val success = nostrViewModel.addContact(contact)
if (success) onDismissRequest()
}
nostrViewModel.addContact(contact)
onDismissRequest()
}) {
Icon(
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.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalRelayViewModel
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room
@@ -114,6 +115,7 @@ fun HomeScreen() {
val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val authViewModel = LocalAuthViewModel.current
val relayViewModel = LocalRelayViewModel.current
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(true)
@@ -123,7 +125,7 @@ fun HomeScreen() {
val userProfile by nostrViewModel.currentUserProfile.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 isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
@@ -319,11 +321,9 @@ fun HomeScreen() {
isRefreshing = isRefreshing,
state = pullToRefreshState,
onRefresh = {
scope.launch {
isRefreshing = true
chatViewModel.refreshChatRooms()
isRefreshing = false
}
},
indicator = {
PullToRefreshDefaults.LoadingIndicator(
@@ -469,7 +469,7 @@ fun HomeScreen() {
// Show the relay setup dialog if the msg relay list is empty
if (isRelayListEmpty) {
ModalBottomSheet(
onDismissRequest = { nostrViewModel.dismissRelayWarning() },
onDismissRequest = { relayViewModel.dismissRelayWarning() },
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surfaceContainer,
) {
@@ -573,15 +573,9 @@ fun HomeScreen() {
TextButton(
enabled = !isBusy,
onClick = {
scope.launch {
isBusy = true
try {
nostrViewModel.refetchMsgRelays()
} catch (e: Exception) {
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
}
relayViewModel.refetchMsgRelays()
isBusy = false
}
},
modifier = Modifier
.weight(1f)
@@ -595,10 +589,8 @@ fun HomeScreen() {
Button(
enabled = !isBusy,
onClick = {
scope.launch {
nostrViewModel.useDefaultMsgRelayList()
sheetState.hide()
}
relayViewModel.useDefaultMsgRelayList()
scope.launch { sheetState.hide() }
},
modifier = Modifier
.weight(1f)

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -29,7 +28,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_cancel
import coop.composeapp.generated.resources.ic_check_circle
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp
@@ -46,7 +44,6 @@ fun ScreenerCard(room: Room) {
val pubkey = room.members.firstOrNull() ?: return
val nostrViewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
var isContact by remember { mutableStateOf(false) }
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
@@ -56,14 +53,12 @@ fun ScreenerCard(room: Room) {
val profile by profileFlow.collectAsStateWithLifecycle()
LaunchedEffect(pubkey) {
scope.launch {
// Check contact
nostrViewModel.verifyContact(pubkey).let { isContact = it }
nostrViewModel.verifyContact(pubkey) { isContact = it }
// Get mutual contacts
nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it }
nostrViewModel.mutualContacts(pubkey) { mutualContacts = it }
// Get the last activity
nostrViewModel.verifyActivity(pubkey)?.let { lastActivity = it }
}
nostrViewModel.verifyActivity(pubkey) { lastActivity = it }
}
Column(

View File

@@ -59,6 +59,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -77,7 +78,11 @@ import su.reya.coop.shared.Avatar
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ChatScreen(id: Long, screening: Boolean = false) {
fun ChatScreen(
id: Long,
screening: Boolean = false,
coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
@@ -121,7 +126,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
val sendFile = { uri: Uri ->
scope.launch {
// Read file on IO dispatcher
val file = withContext(Dispatchers.IO) {
val file = withContext(coroutineDispatcher) {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}
@@ -149,12 +154,13 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
LaunchedEffect(id) {
// Get messages
val initialMessages = chatViewModel.getChatRoomMessages(id)
chatViewModel.loadChatRoomMessages(id) { initialMessages ->
messages.clear()
messages.addAll(initialMessages)
// Stop loading spinner
loading = false
}
// Get msg relays for each member
chatViewModel.chatRoomConnect(id)

View File

@@ -54,6 +54,7 @@ import coil3.compose.AsyncImage
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_plus
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -69,7 +70,8 @@ fun ProfileEditor(
initialBio: String = "",
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
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 snackbarHostState = LocalSnackbarHostState.current
@@ -269,7 +271,7 @@ fun ProfileEditor(
scope.launch {
isBusy = true
try {
val bytes = withContext(Dispatchers.IO) {
val bytes = withContext(ioDispatcher) {
(picture as? Uri)?.let {
context.contentResolver.openInputStream(it)?.readBytes()
}

View File

@@ -1,5 +1,6 @@
package su.reya.coop.nostr
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
@@ -119,7 +120,9 @@ class MessageManager(private val nostr: Nostr) {
setCachedRumor(event.id(), unsignedEvent)
return unsignedEvent
} catch (e: Throwable) {
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}")
return null
}
@@ -131,7 +134,9 @@ class MessageManager(private val nostr: Nostr) {
val event = client?.database()?.query(filter)?.first()
return event?.content()?.let { UnsignedEvent.fromJson(it).ensureId() }
} catch (e: Throwable) {
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
}
}
@@ -155,7 +160,9 @@ class MessageManager(private val nostr: Nostr) {
.finalizeAsync(Keys.generate())
client?.database()?.saveEvent(event)
} catch (e: Throwable) {
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Failed to set cached rumor: ${e.message}")
}
}
@@ -197,6 +204,8 @@ class MessageManager(private val nostr: Nostr) {
}
return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Failed to get chat rooms: ${e.message}")
return null

View File

@@ -1,5 +1,6 @@
package su.reya.coop.nostr
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
@@ -50,7 +51,9 @@ object NostrManager {
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
}
class Nostr {
class Nostr(
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
var client: Client? = null
private set
var signer: UniversalSigner = UniversalSigner(Keys.generate())
@@ -164,7 +167,7 @@ class Nostr {
var processedCount = 0
var eoseReceived = false
launch(Dispatchers.Default) {
launch(defaultDispatcher) {
for (event in giftWrapQueue) {
val rumor = messages.extractRumor(event)
processedCount++

View File

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

View File

@@ -1,10 +1,13 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
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
@@ -32,6 +35,7 @@ class AuthViewModel(
private val storage: AppStorage,
private val mediaRepository: MediaRepository,
private val externalSignerHandler: ExternalSignerHandler? = null,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : BaseViewModel() {
companion object {
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)
// 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
val keys = Keys.generate()
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
return keys
keys
}
private suspend fun createSigner(
secret: String,
password: String? = null
): Pair<AsyncNostrSigner, String?> {
return when {
): Pair<AsyncNostrSigner, String?> = withContext(defaultDispatcher) {
when {
secret.startsWith("nsec1") -> Keys.parse(secret) to null
secret.startsWith("ncryptsec1") -> {

View File

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

View File

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