chore: merge the develop branch into master #1
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -116,3 +126,6 @@ fun NewIdentityScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user