chore: improve performance (#41)

Reviewed-on: #41
This commit was merged in pull request #41.
This commit is contained in:
2026-07-11 12:45:53 +00:00
parent 175c06a1a8
commit a5951e03cf
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.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import su.reya.coop.repository.ErrorRepository
import su.reya.coop.screens.chat.ChatScreen
import kotlinx.coroutines.launch
import su.reya.coop.screens.ContactListScreen
import su.reya.coop.screens.HomeScreen
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.ScanScreen
import su.reya.coop.screens.UpdateProfileScreen
import su.reya.coop.viewmodel.AuthViewModel
import su.reya.coop.screens.chat.ChatScreen
import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.NostrViewModel
import su.reya.coop.viewmodel.account.AccountViewModel
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
error("No NostrViewModel provided")
@@ -60,10 +60,11 @@ val LocalChatViewModel = staticCompositionLocalOf<ChatViewModel> {
error("No ChatViewModel provided")
}
val LocalAuthViewModel = staticCompositionLocalOf<AuthViewModel> {
error("No AuthViewModel provided")
val LocalAccountViewModel = staticCompositionLocalOf<AccountViewModel> {
error("No AccountViewModel provided")
}
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided")
}
@@ -81,7 +82,7 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
fun App(
nostrViewModel: NostrViewModel,
chatViewModel: ChatViewModel,
authViewModel: AuthViewModel,
accountViewModel: AccountViewModel,
) {
val context = LocalContext.current
val activity = context as? ComponentActivity
@@ -90,8 +91,8 @@ fun App(
val qrScanResult = remember { QrScanResult() }
// Get the signer required state
val authState by authViewModel.state.collectAsStateWithLifecycle()
val signerRequired = authState.signerRequired
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val signerRequired = accountState.signerRequired
// Snackbar
val snackbarHostState = remember { SnackbarHostState() }
@@ -118,8 +119,20 @@ fun App(
}
LaunchedEffect(Unit) {
ErrorRepository.errors.collect { message ->
snackbarHostState.showSnackbar(message)
launch {
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(
LocalNostrViewModel provides nostrViewModel,
LocalChatViewModel provides chatViewModel,
LocalAuthViewModel provides authViewModel,
LocalAccountViewModel provides accountViewModel,
LocalSnackbarHostState provides snackbarHostState,
LocalNavigator provides navigator,
LocalScanResult provides qrScanResult,

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

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

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

@@ -33,7 +33,6 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -59,6 +58,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Nip05Address
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
@@ -73,9 +73,10 @@ fun ContactListScreen() {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val nostrViewModel = LocalNostrViewModel.current
val accountViewModel = LocalAccountViewModel.current
val chatViewModel = LocalChatViewModel.current
val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle()
val contactList by accountViewModel.contactList.collectAsStateWithLifecycle()
var openAddContactDialog by remember { mutableStateOf(false) }
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
@@ -202,7 +203,7 @@ fun ContactListScreen() {
confirmButton = {
TextButton(
onClick = {
contactToDelete?.let { nostrViewModel.removeContact(it) }
contactToDelete?.let { accountViewModel.removeContact(it) }
contactToDelete = null
}
) {
@@ -223,6 +224,7 @@ fun ContactListScreen() {
fun AddContactDialog(onDismissRequest: () -> Unit) {
val snackbarHostState = LocalSnackbarHostState.current
val nostrViewModel = LocalNostrViewModel.current
val accountViewModel = LocalAccountViewModel.current
val focusRequester = remember { FocusRequester() }
var contact by remember { mutableStateOf("") }
var isError by remember { mutableStateOf(false) }
@@ -266,10 +268,8 @@ fun AddContactDialog(onDismissRequest: () -> Unit) {
},
actions = {
IconButton(onClick = {
scope.launch {
val success = nostrViewModel.addContact(contact)
if (success) onDismissRequest()
}
accountViewModel.addContact(contact)
onDismissRequest()
}) {
Icon(
painter = painterResource(Res.drawable.ic_check),
@@ -328,7 +328,7 @@ fun ContactListItem(
) {
val nostrViewModel = LocalNostrViewModel.current
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val profile by profileFlow.collectAsState(initial = null)
val profile by profileFlow.collectAsStateWithLifecycle(initialValue = null)
SegmentedListItem(
onClick = onClick,

View File

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

View File

@@ -34,7 +34,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
@@ -44,14 +43,14 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation
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 coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnectUri
import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState
@@ -64,13 +63,13 @@ fun ImportScreen() {
val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current
val focusManager = LocalFocusManager.current
val authViewModel = LocalAuthViewModel.current
val scope = rememberCoroutineScope()
val accountViewModel = LocalAccountViewModel.current
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
var secret by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var requirePassword by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(false) }
LaunchedEffect(qrScanResult.content) {
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(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -164,7 +177,7 @@ fun ImportScreen() {
BasicTextField(
value = secret,
onValueChange = { secret = it },
enabled = !loading,
enabled = !accountState.isImporting,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
@@ -209,7 +222,7 @@ fun ImportScreen() {
BasicTextField(
value = password,
onValueChange = { password = it },
enabled = !loading && requirePassword,
enabled = !accountState.isImporting && requirePassword,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
@@ -237,25 +250,14 @@ fun ImportScreen() {
Spacer(modifier = Modifier.size(16.dp))
Button(
onClick = {
scope.launch {
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
}
}
accountViewModel.importIdentity(secret, password)
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
enabled = secret.isNotBlank() && !loading,
enabled = secret.isNotBlank() && !accountState.isImporting,
) {
if (loading) {
if (accountState.isImporting) {
LoadingIndicator()
} else {
Text(

View File

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

View File

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

View File

@@ -1,22 +1,26 @@
package su.reya.coop.screens
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.Screen
import su.reya.coop.shared.ProfileEditor
@Composable
fun NewIdentityScreen() {
val authViewModel = LocalAuthViewModel.current
val accountViewModel = LocalAccountViewModel.current
val navigator = LocalNavigator.current
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
ProfileEditor(
title = "Create a new identity",
buttonLabel = "Continue",
isBusy = accountState.isImporting,
onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type ->
authViewModel.createIdentity(name, bio, bytes, type)
accountViewModel.createIdentity(name, bio, bytes, type)
navigator.navigate(Screen.Home)
}
)

View File

@@ -24,6 +24,8 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
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.coop
import kotlinx.coroutines.launch
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
@@ -57,9 +60,25 @@ fun OnboardingScreen() {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val authViewModel = LocalAuthViewModel.current
val accountViewModel = LocalAccountViewModel.current
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
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 expressiveFont = getExpressiveFontFamily()
@@ -157,18 +176,12 @@ fun OnboardingScreen() {
Spacer(modifier = Modifier.size(8.dp))
FilledTonalButton(
onClick = {
scope.launch {
if (authViewModel.isExternalSignerAvailable()) {
try {
// Connect to the external signer
// TODO: show all available signers?
authViewModel.connectExternalSigner()
// Navigate to the home screen
navigator.navigate(Screen.Home)
} catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) }
}
} else {
if (accountViewModel.isExternalSignerAvailable()) {
// Connect to the external signer
// TODO: show all available signers?
accountViewModel.connectExternalSigner()
} else {
scope.launch {
val result = snackbarHostState.showSnackbar(
message = "External signer not installed. Please install Amber or alternatives.",
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.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.LocalAccountViewModel
import su.reya.coop.LocalSnackbarHostState
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RelayScreen() {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val nostrViewModel = LocalNostrViewModel.current
val accountViewModel = LocalAccountViewModel.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())
accountViewModel.loadCurrentUserRelayList()
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(
@@ -314,20 +334,16 @@ fun RelayScreen() {
confirmButton = {
TextButton(
onClick = {
scope.launch {
if (msgRelayList.size == 1) {
if (msgRelayList.size == 1) {
scope.launch {
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")
@@ -349,7 +365,7 @@ fun AddRelayDialog(
onMsgRelayAdded: (newRelay: String) -> Unit,
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
) {
val nostrViewModel = LocalNostrViewModel.current
val accountViewModel = LocalAccountViewModel.current
val snackbarHostState = LocalSnackbarHostState.current
val scope = rememberCoroutineScope()
@@ -397,26 +413,24 @@ fun AddRelayDialog(
},
actions = {
IconButton(onClick = {
scope.launch {
if (!isError) {
when (selected) {
"Messaging" -> {
nostrViewModel.addMsgRelay(relayAddress)
onMsgRelayAdded(relayAddress)
}
"Inbox" -> {
nostrViewModel.addInboxRelay(relayAddress)
onRelayAdded(relayAddress, RelayMetadata.WRITE)
}
"Outbox" -> {
nostrViewModel.addOutboxRelay(relayAddress)
onRelayAdded(relayAddress, RelayMetadata.READ)
}
if (!isError) {
when (selected) {
"Messaging" -> {
accountViewModel.addMsgRelay(relayAddress)
onMsgRelayAdded(relayAddress)
}
"Inbox" -> {
accountViewModel.addInboxRelay(relayAddress)
onRelayAdded(relayAddress, RelayMetadata.WRITE)
}
"Outbox" -> {
accountViewModel.addOutboxRelay(relayAddress)
onRelayAdded(relayAddress, RelayMetadata.READ)
}
onDismissRequest()
}
onDismissRequest()
}
}) {
Icon(

View File

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

View File

@@ -3,17 +3,18 @@ package su.reya.coop.screens
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.shared.ProfileEditor
@Composable
fun UpdateProfileScreen() {
val nostrViewModel = LocalNostrViewModel.current
val accountViewModel = LocalAccountViewModel.current
val navigator = LocalNavigator.current
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
val currentUser by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
val profile = currentUser?.metadata?.asRecord()
val isUpdatingProfile by accountViewModel.isUpdatingProfile.collectAsStateWithLifecycle()
ProfileEditor(
title = "Update profile",
@@ -21,9 +22,10 @@ fun UpdateProfileScreen() {
initialName = profile?.displayName ?: profile?.name ?: "",
initialBio = profile?.about ?: "",
initialPicture = profile?.picture,
isBusy = isUpdatingProfile,
onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type ->
nostrViewModel.updateProfile(name, bio, bytes, type)
accountViewModel.updateProfile(name, bio, bytes, type)
navigator.goBack()
}
)

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,10 +28,10 @@ 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
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.Room
import su.reya.coop.humanReadable
@@ -46,7 +45,7 @@ fun ScreenerCard(room: Room) {
val pubkey = room.members.firstOrNull() ?: return
val nostrViewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
val accountViewModel = LocalAccountViewModel.current
var isContact by remember { mutableStateOf(false) }
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
@@ -56,14 +55,12 @@ fun ScreenerCard(room: Room) {
val profile by profileFlow.collectAsStateWithLifecycle()
LaunchedEffect(pubkey) {
scope.launch {
// Check contact
nostrViewModel.verifyContact(pubkey).let { isContact = it }
// Get mutual contacts
nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it }
// Get the last activity
nostrViewModel.verifyActivity(pubkey)?.let { lastActivity = it }
}
// Check contact
accountViewModel.verifyContact(pubkey) { isContact = it }
// Get mutual contacts
accountViewModel.mutualContacts(pubkey) { mutualContacts = it }
// Get the last activity
accountViewModel.verifyActivity(pubkey) { lastActivity = it }
}
Column(

View File

@@ -59,11 +59,13 @@ 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
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
@@ -77,7 +79,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
@@ -87,7 +93,8 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
val listState = rememberLazyListState()
// Get current user
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
val accountViewModel = LocalAccountViewModel.current
val currentUser by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
// Get chat room by ID
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
@@ -120,20 +127,16 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
val sendFile = { uri: Uri ->
scope.launch {
try {
// Read file
val file = withContext(Dispatchers.IO) {
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}")
// Read file on IO dispatcher
val file = withContext(coroutineDispatcher) {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}
// 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) {
// Get messages
val initialMessages = chatViewModel.getChatRoomMessages(id)
messages.clear()
messages.addAll(initialMessages)
chatViewModel.loadChatRoomMessages(id) { initialMessages ->
messages.clear()
messages.addAll(initialMessages)
// Stop loading spinner
loading = false
// 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
@@ -68,8 +69,10 @@ fun ProfileEditor(
initialName: String = "",
initialBio: String = "",
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
isBusy: Boolean = false,
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 snackbarHostState = LocalSnackbarHostState.current
@@ -79,7 +82,6 @@ fun ProfileEditor(
var name by remember(initialName) { mutableStateOf(initialName) }
var bio by remember(initialBio) { mutableStateOf(initialBio) }
var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
var isBusy by remember { mutableStateOf(false) }
val hasPicture = remember(picture) {
when (picture) {
@@ -267,9 +269,8 @@ fun ProfileEditor(
.size(ButtonDefaults.MediumContainerHeight),
onClick = {
scope.launch {
isBusy = true
try {
val bytes = withContext(Dispatchers.IO) {
val bytes = withContext(ioDispatcher) {
(picture as? Uri)?.let {
context.contentResolver.openInputStream(it)?.readBytes()
}
@@ -280,7 +281,6 @@ fun ProfileEditor(
} catch (e: Exception) {
snackbarHostState.showSnackbar(e.message ?: "Error")
}
isBusy = false
}
},
enabled = name.isNotBlank() && !isBusy