chore: merge the develop branch into master #1

Merged
reya merged 43 commits from develop into master 2026-05-23 00:50:13 +00:00
3 changed files with 156 additions and 101 deletions
Showing only changes of commit 8b5a8b0e48 - Show all commits

View File

@@ -3,6 +3,7 @@ package su.reya.coop
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
@@ -32,6 +33,10 @@ val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
error("No NostrViewModel provided") error("No NostrViewModel provided")
} }
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided")
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun App(dbPath: String) { fun App(dbPath: String) {
@@ -53,24 +58,32 @@ fun App(dbPath: String) {
else -> expressiveLightColorScheme() else -> expressiveLightColorScheme()
} }
// Snackbar
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.initAndConnect(dbPath) viewModel.initAndConnect(dbPath)
viewModel.startNotificationHandler()
viewModel.getChatRooms() viewModel.getChatRooms()
viewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
} }
MaterialExpressiveTheme( MaterialExpressiveTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
) { ) {
CompositionLocalProvider(LocalNostrViewModel provides viewModel) { CompositionLocalProvider(
LocalNostrViewModel provides viewModel,
LocalSnackbarHostState provides snackbarHostState,
) {
rememberCoroutineScope() rememberCoroutineScope()
val navController = rememberNavController() val navController = rememberNavController()
val hasSecret by viewModel.hasSecret.collectAsState(initial = null) val emptySecret by viewModel.emptySecret.collectAsState(initial = null)
LaunchedEffect(hasSecret) { LaunchedEffect(emptySecret) {
// Navigate to the home screen if the secret is already set // Navigate to the home screen if the secret is already set
if (hasSecret == true) { if (emptySecret == false) {
// Start a background notification handler
viewModel.startNotificationHandler()
// Get chat rooms // Get chat rooms
viewModel.getChatRooms() viewModel.getChatRooms()
// Navigate to the home screen // Navigate to the home screen
@@ -81,11 +94,11 @@ fun App(dbPath: String) {
} }
// Show loading screen while initializing // Show loading screen while initializing
if (hasSecret == null) return@CompositionLocalProvider if (emptySecret == null) return@CompositionLocalProvider
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = if (hasSecret == true) Screen.Home else Screen.Onboarding startDestination = if (emptySecret == false) Screen.Home else Screen.Onboarding
) { ) {
composable<Screen.Onboarding> { backStackEntry -> composable<Screen.Onboarding> { backStackEntry ->
OnboardingScreen( OnboardingScreen(

View File

@@ -20,6 +20,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -33,6 +35,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import su.reya.coop.LocalSnackbarHostState
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@@ -40,6 +43,7 @@ fun NewIdentityScreen(
isLoading: Boolean, isLoading: Boolean,
onSave: (name: String, bio: String, picture: Uri?) -> Unit onSave: (name: String, bio: String, picture: Uri?) -> Unit
) { ) {
val snackbarHostState = LocalSnackbarHostState.current
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
var bio by remember { mutableStateOf("") } var bio by remember { mutableStateOf("") }
var picture by remember { mutableStateOf<Uri?>(null) } var picture by remember { mutableStateOf<Uri?>(null) }
@@ -49,6 +53,16 @@ fun NewIdentityScreen(
picture = uri picture = uri
} }
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
content = { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding()),
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -57,10 +71,6 @@ fun NewIdentityScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Text(
text = "New Identity",
style = MaterialTheme.typography.headlineMediumEmphasized
)
Box( Box(
modifier = Modifier modifier = Modifier
.size(120.dp) .size(120.dp)
@@ -115,4 +125,7 @@ fun NewIdentityScreen(
} }
} }
} }
}
}
)
} }

View File

@@ -7,6 +7,7 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
@@ -23,8 +24,8 @@ class NostrViewModel(
private val nostr: Nostr, private val nostr: Nostr,
private val secretStore: SecretStorage private val secretStore: SecretStorage
) : ViewModel() { ) : ViewModel() {
private val _hasSecret = MutableStateFlow<Boolean?>(null) private val _emptySecret = MutableStateFlow<Boolean?>(null)
val hasSecret = _hasSecret.asStateFlow() val emptySecret = _emptySecret.asStateFlow()
private val _isCreating = MutableStateFlow(false) private val _isCreating = MutableStateFlow(false)
val isCreating = _isCreating.asStateFlow() val isCreating = _isCreating.asStateFlow()
@@ -32,6 +33,9 @@ class NostrViewModel(
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet()) private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
val chatRooms = _chatRooms.asStateFlow() val chatRooms = _chatRooms.asStateFlow()
private val _errorEvents = Channel<String>(Channel.BUFFERED)
val errorEvents = _errorEvents.receiveAsFlow()
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>() private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED) private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>() private val seenPublicKeys = mutableSetOf<PublicKey>()
@@ -40,6 +44,12 @@ class NostrViewModel(
startMetadataBatchProcessor() startMetadataBatchProcessor()
} }
private fun showError(message: String) {
viewModelScope.launch {
_errorEvents.send(message)
}
}
private fun startMetadataBatchProcessor() { private fun startMetadataBatchProcessor() {
viewModelScope.launch { viewModelScope.launch {
val batch = mutableSetOf<PublicKey>() val batch = mutableSetOf<PublicKey>()
@@ -91,11 +101,7 @@ class NostrViewModel(
} }
fun getUserProfile(): StateFlow<Metadata?> { fun getUserProfile(): StateFlow<Metadata?> {
return try { return getMetadata(nostr.userPubkey!!)
getMetadata(nostr.userPubkey!!)
} catch (e: Exception) {
MutableStateFlow(null)
}
} }
fun initAndConnect(dbPath: String) { fun initAndConnect(dbPath: String) {
@@ -108,7 +114,7 @@ class NostrViewModel(
// Get user's secret // Get user's secret
getUserSecret() getUserSecret()
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to connect: ${e.message}") showError("Failed to initialize Nostr: ${e.message}")
} }
} }
} }
@@ -121,31 +127,43 @@ class NostrViewModel(
} }
} }
fun logout() {
viewModelScope.launch {
_emptySecret.value = true
_chatRooms.value = emptySet()
secretStore.clear("user_signer")
nostr.exit()
}
}
suspend fun getUserSecret() { suspend fun getUserSecret() {
// Get user's signer secret // Get user's signer secret
val secret = secretStore.get("user_signer") val secret = secretStore.get("user_signer")
// If no secret is found, show onboarding screen // If no secret is found, show onboarding screen
if (secret == null) { when (secret) {
_hasSecret.value = false null -> {
_emptySecret.value = true
return return
} }
_hasSecret.value = true
else -> _emptySecret.value = false
}
// Handle different signer types // Handle different signer types
if (secret.startsWith("nsec1")) { if (secret.startsWith("nsec1")) {
val keys = Keys.parse(secret) val keys = Keys.parse(secret)
nostr.setKeySigner(keys) nostr.setKeySigner(keys)
} else if (secret.startsWith("bunker://")) { } else if (secret.startsWith("bunker://")) {
try {
val appKeys = getOrInitAppKeys() val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret) val bunker = NostrConnectUri.parse(secret)
val remote = NostrConnect( val timeout = Duration.parse("50") // 50 seconds timeout
uri = bunker, val remote = NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null)
appKeys = appKeys,
timeout = Duration.parse("5"),
opts = null
)
nostr.setRemoteSigner(remote) nostr.setRemoteSigner(remote)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
} else { } else {
throw IllegalArgumentException("Invalid secret format: $secret") throw IllegalArgumentException("Invalid secret format: $secret")
} }
@@ -178,21 +196,32 @@ class NostrViewModel(
// Save secret to the secret storage // Save secret to the secret storage
secretStore.set("user_signer", secret) secretStore.set("user_signer", secret)
} catch (e: Exception) { } catch (e: Exception) {
println("Create identity failed: $e") showError("Error: ${e.message}")
} }
} }
} }
fun importIdentity(secret: String) { fun importIdentity(secret: String) {
// TODO: Implement import
}
fun logout() {
viewModelScope.launch { viewModelScope.launch {
_hasSecret.value = false if (secret.startsWith("nsec1")) {
_chatRooms.value = emptySet() val keys = Keys.parse(secret)
secretStore.clear("user_signer") nostr.setKeySigner(keys)
nostr.exit() secretStore.set("user_signer", secret)
} else if (secret.startsWith("bunker://")) {
try {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50") // 50 seconds timeout
val remote =
NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null)
nostr.setRemoteSigner(remote)
secretStore.set("user_signer", secret)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
} else {
showError("Please enter a valid Secret or Bunker URI.")
}
} }
} }
@@ -201,7 +230,7 @@ class NostrViewModel(
try { try {
_chatRooms.value = nostr.getChatRooms() ?: emptySet() _chatRooms.value = nostr.getChatRooms() ?: emptySet()
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to get chat rooms: ${e.message}") showError("Error: ${e.message}")
} }
} }
} }