chore: merge the develop branch into master #1
@@ -3,6 +3,7 @@ package su.reya.coop
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialExpressiveTheme
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
@@ -32,6 +33,10 @@ val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
|
||||
error("No NostrViewModel provided")
|
||||
}
|
||||
|
||||
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||
error("No SnackbarHostState provided")
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun App(dbPath: String) {
|
||||
@@ -53,24 +58,32 @@ fun App(dbPath: String) {
|
||||
else -> expressiveLightColorScheme()
|
||||
}
|
||||
|
||||
// Snackbar
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.initAndConnect(dbPath)
|
||||
viewModel.startNotificationHandler()
|
||||
viewModel.getChatRooms()
|
||||
viewModel.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = colorScheme,
|
||||
) {
|
||||
CompositionLocalProvider(LocalNostrViewModel provides viewModel) {
|
||||
CompositionLocalProvider(
|
||||
LocalNostrViewModel provides viewModel,
|
||||
LocalSnackbarHostState provides snackbarHostState,
|
||||
) {
|
||||
rememberCoroutineScope()
|
||||
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
|
||||
if (hasSecret == true) {
|
||||
// Start a background notification handler
|
||||
viewModel.startNotificationHandler()
|
||||
if (emptySecret == false) {
|
||||
// Get chat rooms
|
||||
viewModel.getChatRooms()
|
||||
// Navigate to the home screen
|
||||
@@ -81,11 +94,11 @@ fun App(dbPath: String) {
|
||||
}
|
||||
|
||||
// Show loading screen while initializing
|
||||
if (hasSecret == null) return@CompositionLocalProvider
|
||||
if (emptySecret == null) return@CompositionLocalProvider
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = if (hasSecret == true) Screen.Home else Screen.Onboarding
|
||||
startDestination = if (emptySecret == false) Screen.Home else Screen.Onboarding
|
||||
) {
|
||||
composable<Screen.Onboarding> { backStackEntry ->
|
||||
OnboardingScreen(
|
||||
|
||||
@@ -20,6 +20,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -40,6 +43,7 @@ fun NewIdentityScreen(
|
||||
isLoading: Boolean,
|
||||
onSave: (name: String, bio: String, picture: Uri?) -> Unit
|
||||
) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
var name by remember { mutableStateOf("") }
|
||||
var bio by remember { mutableStateOf("") }
|
||||
var picture by remember { mutableStateOf<Uri?>(null) }
|
||||
@@ -49,6 +53,16 @@ fun NewIdentityScreen(
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -57,10 +71,6 @@ fun NewIdentityScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "New Identity",
|
||||
style = MaterialTheme.typography.headlineMediumEmphasized
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
@@ -115,4 +125,7 @@ fun NewIdentityScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
@@ -23,8 +24,8 @@ class NostrViewModel(
|
||||
private val nostr: Nostr,
|
||||
private val secretStore: SecretStorage
|
||||
) : ViewModel() {
|
||||
private val _hasSecret = MutableStateFlow<Boolean?>(null)
|
||||
val hasSecret = _hasSecret.asStateFlow()
|
||||
private val _emptySecret = MutableStateFlow<Boolean?>(null)
|
||||
val emptySecret = _emptySecret.asStateFlow()
|
||||
|
||||
private val _isCreating = MutableStateFlow(false)
|
||||
val isCreating = _isCreating.asStateFlow()
|
||||
@@ -32,6 +33,9 @@ class NostrViewModel(
|
||||
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
||||
val chatRooms = _chatRooms.asStateFlow()
|
||||
|
||||
private val _errorEvents = Channel<String>(Channel.BUFFERED)
|
||||
val errorEvents = _errorEvents.receiveAsFlow()
|
||||
|
||||
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
|
||||
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||
@@ -40,6 +44,12 @@ class NostrViewModel(
|
||||
startMetadataBatchProcessor()
|
||||
}
|
||||
|
||||
private fun showError(message: String) {
|
||||
viewModelScope.launch {
|
||||
_errorEvents.send(message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startMetadataBatchProcessor() {
|
||||
viewModelScope.launch {
|
||||
val batch = mutableSetOf<PublicKey>()
|
||||
@@ -91,11 +101,7 @@ class NostrViewModel(
|
||||
}
|
||||
|
||||
fun getUserProfile(): StateFlow<Metadata?> {
|
||||
return try {
|
||||
getMetadata(nostr.userPubkey!!)
|
||||
} catch (e: Exception) {
|
||||
MutableStateFlow(null)
|
||||
}
|
||||
return getMetadata(nostr.userPubkey!!)
|
||||
}
|
||||
|
||||
fun initAndConnect(dbPath: String) {
|
||||
@@ -108,7 +114,7 @@ class NostrViewModel(
|
||||
// Get user's secret
|
||||
getUserSecret()
|
||||
} 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() {
|
||||
// Get user's signer secret
|
||||
val secret = secretStore.get("user_signer")
|
||||
|
||||
// If no secret is found, show onboarding screen
|
||||
if (secret == null) {
|
||||
_hasSecret.value = false
|
||||
when (secret) {
|
||||
null -> {
|
||||
_emptySecret.value = true
|
||||
return
|
||||
}
|
||||
_hasSecret.value = true
|
||||
|
||||
else -> _emptySecret.value = false
|
||||
}
|
||||
|
||||
// Handle different signer types
|
||||
if (secret.startsWith("nsec1")) {
|
||||
val keys = Keys.parse(secret)
|
||||
nostr.setKeySigner(keys)
|
||||
} else if (secret.startsWith("bunker://")) {
|
||||
try {
|
||||
val appKeys = getOrInitAppKeys()
|
||||
val bunker = NostrConnectUri.parse(secret)
|
||||
val remote = NostrConnect(
|
||||
uri = bunker,
|
||||
appKeys = appKeys,
|
||||
timeout = Duration.parse("5"),
|
||||
opts = null
|
||||
)
|
||||
val timeout = Duration.parse("50") // 50 seconds timeout
|
||||
val remote = NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null)
|
||||
nostr.setRemoteSigner(remote)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
throw IllegalArgumentException("Invalid secret format: $secret")
|
||||
}
|
||||
@@ -178,21 +196,32 @@ class NostrViewModel(
|
||||
// Save secret to the secret storage
|
||||
secretStore.set("user_signer", secret)
|
||||
} catch (e: Exception) {
|
||||
println("Create identity failed: $e")
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun importIdentity(secret: String) {
|
||||
// TODO: Implement import
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
_hasSecret.value = false
|
||||
_chatRooms.value = emptySet()
|
||||
secretStore.clear("user_signer")
|
||||
nostr.exit()
|
||||
if (secret.startsWith("nsec1")) {
|
||||
val keys = Keys.parse(secret)
|
||||
nostr.setKeySigner(keys)
|
||||
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 {
|
||||
_chatRooms.value = nostr.getChatRooms() ?: emptySet()
|
||||
} catch (e: Exception) {
|
||||
println("Failed to get chat rooms: ${e.message}")
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user