Compare commits
10 Commits
v0.2.3
...
38b704fe18
| Author | SHA1 | Date | |
|---|---|---|---|
| 38b704fe18 | |||
| e0701504aa | |||
| 175c06a1a8 | |||
| c7a646ae73 | |||
| 7a44e836a9 | |||
| 4dae4045ae | |||
| 9df4789b62 | |||
| ea7e3fd3ed | |||
| a76c8d117b | |||
| 9ed29c90ba |
@@ -69,7 +69,7 @@ android {
|
|||||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.2.3"
|
versionName = "0.2.4"
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:autoMirrored="true"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M480,840L480,760L760,760Q760,760 760,760Q760,760 760,760L760,200Q760,200 760,200Q760,200 760,200L480,200L480,120L760,120Q793,120 816.5,143.5Q840,167 840,200L840,760Q840,793 816.5,816.5Q793,840 760,840L480,840ZM400,680L345,622L447,520L120,520L120,440L447,440L345,338L400,280L600,480L400,680Z" />
|
||||||
|
</vector>
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
package su.reya.coop.coop.storage
|
package su.reya.coop
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.security.keystore.KeyGenParameterSpec
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
import android.security.keystore.KeyProperties
|
import android.security.keystore.KeyProperties
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
@@ -10,10 +15,9 @@ import javax.crypto.KeyGenerator
|
|||||||
import javax.crypto.SecretKey
|
import javax.crypto.SecretKey
|
||||||
import javax.crypto.spec.GCMParameterSpec
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
data class SecretEntry(
|
private val Context.dataStore by preferencesDataStore("secret_store")
|
||||||
val encrypted: String,
|
|
||||||
val iv: String
|
data class SecretEntry(val encrypted: String, val iv: String)
|
||||||
)
|
|
||||||
|
|
||||||
class SecretCrypto {
|
class SecretCrypto {
|
||||||
private val keyAlias = "coop"
|
private val keyAlias = "coop"
|
||||||
@@ -21,11 +25,9 @@ class SecretCrypto {
|
|||||||
private val transformation = "AES/GCM/NoPadding"
|
private val transformation = "AES/GCM/NoPadding"
|
||||||
|
|
||||||
fun encrypt(content: String): SecretEntry {
|
fun encrypt(content: String): SecretEntry {
|
||||||
// Initialize cipher
|
|
||||||
val cipher = Cipher.getInstance(transformation)
|
val cipher = Cipher.getInstance(transformation)
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
||||||
|
|
||||||
// Encrypt content
|
|
||||||
val encrypted = cipher.doFinal(content.toByteArray())
|
val encrypted = cipher.doFinal(content.toByteArray())
|
||||||
val iv = cipher.iv
|
val iv = cipher.iv
|
||||||
|
|
||||||
@@ -39,12 +41,10 @@ class SecretCrypto {
|
|||||||
val encrypted = Base64.decode(entry.encrypted, Base64.NO_WRAP)
|
val encrypted = Base64.decode(entry.encrypted, Base64.NO_WRAP)
|
||||||
val iv = Base64.decode(entry.iv, Base64.NO_WRAP)
|
val iv = Base64.decode(entry.iv, Base64.NO_WRAP)
|
||||||
|
|
||||||
// Initialize cipher
|
|
||||||
val cipher = Cipher.getInstance(transformation)
|
val cipher = Cipher.getInstance(transformation)
|
||||||
val spec = GCMParameterSpec(128, iv)
|
val spec = GCMParameterSpec(128, iv)
|
||||||
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
|
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
|
||||||
|
|
||||||
// Decrypt content
|
|
||||||
val plaintext = cipher.doFinal(encrypted)
|
val plaintext = cipher.doFinal(encrypted)
|
||||||
|
|
||||||
return String(plaintext, StandardCharsets.UTF_8)
|
return String(plaintext, StandardCharsets.UTF_8)
|
||||||
@@ -54,13 +54,9 @@ class SecretCrypto {
|
|||||||
val keyStore = KeyStore.getInstance(keyStoreType).apply { load(null) }
|
val keyStore = KeyStore.getInstance(keyStoreType).apply { load(null) }
|
||||||
val existingKey = keyStore.getKey(keyAlias, null)
|
val existingKey = keyStore.getKey(keyAlias, null)
|
||||||
|
|
||||||
// Return existing key if available
|
|
||||||
if (existingKey is SecretKey) return existingKey
|
if (existingKey is SecretKey) return existingKey
|
||||||
|
|
||||||
// Construct a new key generator
|
|
||||||
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStoreType)
|
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStoreType)
|
||||||
|
|
||||||
// Initialize key generation parameters
|
|
||||||
val spec = KeyGenParameterSpec.Builder(
|
val spec = KeyGenParameterSpec.Builder(
|
||||||
keyAlias,
|
keyAlias,
|
||||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||||
@@ -70,9 +66,49 @@ class SecretCrypto {
|
|||||||
.setKeySize(256)
|
.setKeySize(256)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
// Generate a new key
|
|
||||||
keyGenerator.init(spec)
|
keyGenerator.init(spec)
|
||||||
|
|
||||||
return keyGenerator.generateKey()
|
return keyGenerator.generateKey()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AppStore(private val context: Context) : AppStorage {
|
||||||
|
private val crypto = SecretCrypto()
|
||||||
|
|
||||||
|
override suspend fun get(key: String): String? {
|
||||||
|
return context.dataStore.data.first()[stringPreferencesKey(key)]
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun set(key: String, value: String) {
|
||||||
|
context.dataStore.edit { it[stringPreferencesKey(key)] = value }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getSecret(key: String): String? {
|
||||||
|
val prefs = context.dataStore.data.first()
|
||||||
|
val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null
|
||||||
|
val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null
|
||||||
|
|
||||||
|
return crypto.decrypt(SecretEntry(encrypted, iv))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setSecret(key: String, value: String) {
|
||||||
|
val entry = crypto.encrypt(value)
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[stringPreferencesKey("${key}_encrypted")] = entry.encrypted
|
||||||
|
prefs[stringPreferencesKey("${key}_iv")] = entry.iv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clear(key: String) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs.remove(stringPreferencesKey(key))
|
||||||
|
prefs.remove(stringPreferencesKey("${key}_encrypted"))
|
||||||
|
prefs.remove(stringPreferencesKey("${key}_iv"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun has(key: String): Boolean {
|
||||||
|
val prefs = context.dataStore.data.first()
|
||||||
|
return prefs.contains(stringPreferencesKey(key)) ||
|
||||||
|
prefs.contains(stringPreferencesKey("${key}_encrypted"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ import androidx.activity.viewModels
|
|||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import su.reya.coop.coop.storage.SecretStore
|
|
||||||
import su.reya.coop.nostr.NostrManager
|
import su.reya.coop.nostr.NostrManager
|
||||||
import su.reya.coop.viewmodel.AuthViewModel
|
import su.reya.coop.viewmodel.AuthViewModel
|
||||||
import su.reya.coop.viewmodel.ChatViewModel
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
@@ -26,15 +25,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private val factory by lazy {
|
private val factory by lazy {
|
||||||
object : ViewModelProvider.Factory {
|
object : ViewModelProvider.Factory {
|
||||||
|
private val storage = AppStore(this@MainActivity)
|
||||||
|
private val nostrViewModel = NostrViewModel(NostrManager.instance)
|
||||||
|
private val chatViewModel = ChatViewModel(NostrManager.instance)
|
||||||
private val androidSigner =
|
private val androidSigner =
|
||||||
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
||||||
private val secretStore = SecretStore(this@MainActivity)
|
|
||||||
private val nostrViewModel =
|
|
||||||
NostrViewModel(NostrManager.instance)
|
|
||||||
private val chatViewModel =
|
|
||||||
ChatViewModel(NostrManager.instance)
|
|
||||||
private val authViewModel =
|
private val authViewModel =
|
||||||
AuthViewModel(NostrManager.instance, secretStore, androidSigner)
|
AuthViewModel(NostrManager.instance, storage, androidSigner)
|
||||||
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
return when {
|
return when {
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ fun HomeScreen() {
|
|||||||
onPauseOrDispose { }
|
onPauseOrDispose { }
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(authState.signerRequired) {
|
||||||
chatViewModel.refreshChatRooms()
|
chatViewModel.refreshChatRooms()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,7 +626,7 @@ fun NewRequests(requests: List<Room>) {
|
|||||||
val secondRoom = requests.getOrNull(1)
|
val secondRoom = requests.getOrNull(1)
|
||||||
|
|
||||||
val firstRoomState by (firstRoom as Room).rememberUiState(nostrViewModel)
|
val firstRoomState by (firstRoom as Room).rememberUiState(nostrViewModel)
|
||||||
val secondRoomState by (secondRoom as Room).rememberUiState(nostrViewModel)
|
val secondRoomState by (secondRoom ?: firstRoom).rememberUiState(nostrViewModel)
|
||||||
|
|
||||||
val supportingText = when {
|
val supportingText = when {
|
||||||
total == 1 -> {
|
total == 1 -> {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LoadingIndicator
|
import androidx.compose.material3.LoadingIndicator
|
||||||
import androidx.compose.material3.MaterialShapes
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
@@ -30,7 +29,6 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.toShape
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -40,32 +38,24 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import coop.composeapp.generated.resources.ic_scanner
|
import coop.composeapp.generated.resources.ic_scanner
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.Keys
|
import rust.nostr.sdk.Keys
|
||||||
import rust.nostr.sdk.NostrConnectUri
|
import rust.nostr.sdk.NostrConnectUri
|
||||||
import rust.nostr.sdk.PublicKey
|
|
||||||
import su.reya.coop.LocalAuthViewModel
|
import su.reya.coop.LocalAuthViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
|
||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.Avatar
|
|
||||||
import su.reya.coop.shared.getExpressiveFontFamily
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -74,24 +64,18 @@ fun ImportScreen() {
|
|||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val qrScanResult = LocalScanResult.current
|
val qrScanResult = LocalScanResult.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val nostrViewModel = LocalNostrViewModel.current
|
|
||||||
val authViewModel = LocalAuthViewModel.current
|
val authViewModel = LocalAuthViewModel.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val authState by authViewModel.state.collectAsStateWithLifecycle()
|
|
||||||
val isBusy = authState.isBusy
|
|
||||||
var secret by remember { mutableStateOf("") }
|
var secret by remember { mutableStateOf("") }
|
||||||
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
var password by remember { mutableStateOf("") }
|
||||||
|
var requirePassword by remember { mutableStateOf(false) }
|
||||||
val profile by remember(pubkey) {
|
var loading by remember { mutableStateOf(false) }
|
||||||
pubkey?.let(nostrViewModel::getMetadata) ?: flowOf(null)
|
|
||||||
}.collectAsStateWithLifecycle(null)
|
|
||||||
|
|
||||||
LaunchedEffect(qrScanResult.content) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
qrScanResult.content?.let { result ->
|
qrScanResult.content?.let { result ->
|
||||||
runCatching {
|
runCatching {
|
||||||
if (result.startsWith("nsec1")) {
|
if (result.startsWith("nsec1") || result.startsWith("ncryptsec1")) {
|
||||||
Keys.parse(result)
|
Keys.parse(result)
|
||||||
} else if (result.startsWith("bunker://")) {
|
} else if (result.startsWith("bunker://")) {
|
||||||
NostrConnectUri.parse(result)
|
NostrConnectUri.parse(result)
|
||||||
@@ -101,13 +85,19 @@ fun ImportScreen() {
|
|||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
secret = result
|
secret = result
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
snackbarHostState.showSnackbar("Invalid secret: ${e.message}")
|
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||||
}
|
}
|
||||||
// Clear the nav state
|
// Clear the nav state
|
||||||
qrScanResult.clear()
|
qrScanResult.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(secret) {
|
||||||
|
if (secret.startsWith("ncryptsec1")) {
|
||||||
|
requirePassword = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
@@ -147,35 +137,6 @@ fun ImportScreen() {
|
|||||||
.padding(top = innerPadding.calculateTopPadding())
|
.padding(top = innerPadding.calculateTopPadding())
|
||||||
.imePadding(),
|
.imePadding(),
|
||||||
) {
|
) {
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(120.dp)
|
|
||||||
.clip(MaterialShapes.Cookie9Sided.toShape()),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Avatar(
|
|
||||||
picture = profile?.picture,
|
|
||||||
description = "Profile picture",
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
shape = MaterialShapes.Cookie9Sided.toShape(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
Text(
|
|
||||||
text = profile?.name ?: "",
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
|
||||||
fontFamily = getExpressiveFontFamily()
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -186,7 +147,7 @@ fun ImportScreen() {
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(24.dp)
|
.padding(24.dp),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -203,9 +164,9 @@ fun ImportScreen() {
|
|||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = secret,
|
value = secret,
|
||||||
onValueChange = { secret = it },
|
onValueChange = { secret = it },
|
||||||
enabled = !isBusy,
|
enabled = !loading,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxLines = 4,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
imeAction = ImeAction.Done,
|
imeAction = ImeAction.Done,
|
||||||
),
|
),
|
||||||
@@ -237,32 +198,68 @@ fun ImportScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
if (requirePassword) {
|
||||||
|
Text(
|
||||||
|
text = "Decrypt Password:",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
BasicTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
enabled = !loading && requirePassword,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
visualTransformation = PasswordVisualTransformation('*'),
|
||||||
|
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
|
||||||
|
color = MaterialTheme.colorScheme.tertiaryFixedDim,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
|
||||||
|
decorationBox = { innerTextField ->
|
||||||
|
Box(contentAlignment = Alignment.CenterStart) {
|
||||||
|
innerTextField()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (pubkey == null) {
|
loading = true
|
||||||
authViewModel.verifyIdentity(secret).let { pubkey = it }
|
try {
|
||||||
} else {
|
|
||||||
// Import the identity
|
// Import the identity
|
||||||
authViewModel.importIdentity(secret)
|
authViewModel.importIdentity(secret, password)
|
||||||
// Navigate to the home screen
|
// Navigate to the home screen
|
||||||
navigator.navigate(Screen.Home)
|
navigator.navigate(Screen.Home)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
snackbarHostState.showSnackbar(e.message ?: "Error")
|
||||||
|
loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(ButtonDefaults.MediumContainerHeight),
|
.height(ButtonDefaults.MediumContainerHeight),
|
||||||
enabled = secret.isNotBlank() && !isBusy,
|
enabled = secret.isNotBlank() && !loading,
|
||||||
) {
|
) {
|
||||||
if (isBusy) {
|
if (loading) {
|
||||||
LoadingIndicator()
|
LoadingIndicator()
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = if (pubkey == null) "Verify" else "Click again to Continue",
|
text = "Continue",
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
package su.reya.coop.screens
|
package su.reya.coop.screens
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import su.reya.coop.LocalAuthViewModel
|
import su.reya.coop.LocalAuthViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
@@ -14,21 +10,14 @@ import su.reya.coop.shared.ProfileEditor
|
|||||||
fun NewIdentityScreen() {
|
fun NewIdentityScreen() {
|
||||||
val authViewModel = LocalAuthViewModel.current
|
val authViewModel = LocalAuthViewModel.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
val authState by authViewModel.state.collectAsStateWithLifecycle()
|
|
||||||
val isBusy = authState.isBusy
|
|
||||||
|
|
||||||
ProfileEditor(
|
ProfileEditor(
|
||||||
title = "Create a new identity",
|
title = "Create a new identity",
|
||||||
buttonLabel = "Continue",
|
buttonLabel = "Continue",
|
||||||
isBusy = isBusy,
|
|
||||||
onBack = { navigator.goBack() },
|
onBack = { navigator.goBack() },
|
||||||
onConfirm = { name, bio, bytes, type ->
|
onConfirm = { name, bio, bytes, type ->
|
||||||
scope.launch {
|
authViewModel.createIdentity(name, bio, bytes, type)
|
||||||
authViewModel.createIdentity(name, bio, bytes, type)
|
navigator.navigate(Screen.Home)
|
||||||
navigator.navigate(Screen.Home)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,10 @@ fun OnboardingScreen() {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
if (authViewModel.isExternalSignerAvailable()) {
|
if (authViewModel.isExternalSignerAvailable()) {
|
||||||
try {
|
try {
|
||||||
|
// Connect to the external signer
|
||||||
|
// TODO: show all available signers?
|
||||||
authViewModel.connectExternalSigner()
|
authViewModel.connectExternalSigner()
|
||||||
|
// Navigate to the home screen
|
||||||
navigator.navigate(Screen.Home)
|
navigator.navigate(Screen.Home)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ package su.reya.coop.screens
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.shared.ProfileEditor
|
import su.reya.coop.shared.ProfileEditor
|
||||||
@@ -13,9 +11,7 @@ import su.reya.coop.shared.ProfileEditor
|
|||||||
fun UpdateProfileScreen() {
|
fun UpdateProfileScreen() {
|
||||||
val nostrViewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false)
|
|
||||||
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
val profile = currentUser?.metadata?.asRecord()
|
val profile = currentUser?.metadata?.asRecord()
|
||||||
|
|
||||||
@@ -25,13 +21,10 @@ fun UpdateProfileScreen() {
|
|||||||
initialName = profile?.displayName ?: profile?.name ?: "",
|
initialName = profile?.displayName ?: profile?.name ?: "",
|
||||||
initialBio = profile?.about ?: "",
|
initialBio = profile?.about ?: "",
|
||||||
initialPicture = profile?.picture,
|
initialPicture = profile?.picture,
|
||||||
isBusy = isBusy,
|
|
||||||
onBack = { navigator.goBack() },
|
onBack = { navigator.goBack() },
|
||||||
onConfirm = { name, bio, bytes, type ->
|
onConfirm = { name, bio, bytes, type ->
|
||||||
scope.launch {
|
nostrViewModel.updateProfile(name, bio, bytes, type)
|
||||||
nostrViewModel.updateProfile(name, bio, bytes, type)
|
navigator.goBack()
|
||||||
navigator.goBack()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -23,6 +24,8 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_add_circle
|
import coop.composeapp.generated.resources.ic_add_circle
|
||||||
@@ -52,6 +55,11 @@ fun ChatInput(
|
|||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
placeholder = { Text("Message") },
|
placeholder = { Text("Message") },
|
||||||
|
maxLines = 5,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.Sentences,
|
||||||
|
imeAction = ImeAction.Default
|
||||||
|
),
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
IconButton(onClick = onUpload) {
|
IconButton(onClick = onUpload) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
@@ -68,9 +68,8 @@ fun ProfileEditor(
|
|||||||
initialName: String = "",
|
initialName: String = "",
|
||||||
initialBio: String = "",
|
initialBio: String = "",
|
||||||
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
|
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
|
||||||
isBusy: Boolean = false,
|
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit
|
onConfirm: suspend (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
@@ -80,6 +79,7 @@ fun ProfileEditor(
|
|||||||
var name by remember(initialName) { mutableStateOf(initialName) }
|
var name by remember(initialName) { mutableStateOf(initialName) }
|
||||||
var bio by remember(initialBio) { mutableStateOf(initialBio) }
|
var bio by remember(initialBio) { mutableStateOf(initialBio) }
|
||||||
var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
|
var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
|
||||||
|
var isBusy by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val hasPicture = remember(picture) {
|
val hasPicture = remember(picture) {
|
||||||
when (picture) {
|
when (picture) {
|
||||||
@@ -267,14 +267,20 @@ fun ProfileEditor(
|
|||||||
.size(ButtonDefaults.MediumContainerHeight),
|
.size(ButtonDefaults.MediumContainerHeight),
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val bytes = withContext(Dispatchers.IO) {
|
isBusy = true
|
||||||
(picture as? Uri)?.let {
|
try {
|
||||||
context.contentResolver.openInputStream(it)?.readBytes()
|
val bytes = withContext(Dispatchers.IO) {
|
||||||
|
(picture as? Uri)?.let {
|
||||||
|
context.contentResolver.openInputStream(it)?.readBytes()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
val type =
|
||||||
|
(picture as? Uri)?.let { context.contentResolver.getType(it) }
|
||||||
|
onConfirm(name, bio, bytes, type)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
snackbarHostState.showSnackbar(e.message ?: "Error")
|
||||||
}
|
}
|
||||||
val type =
|
isBusy = false
|
||||||
(picture as? Uri)?.let { context.contentResolver.getType(it) }
|
|
||||||
onConfirm(name, bio, bytes, type)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = name.isNotBlank() && !isBusy
|
enabled = name.isNotBlank() && !isBusy
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
package su.reya.coop.coop.storage
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.datastore.preferences.core.edit
|
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import su.reya.coop.storage.SecretStorage
|
|
||||||
|
|
||||||
private val Context.dataStore by preferencesDataStore("secret_store")
|
|
||||||
|
|
||||||
class SecretStore(private val context: Context) : SecretStorage {
|
|
||||||
private val crypto = SecretCrypto()
|
|
||||||
|
|
||||||
override suspend fun set(key: String, value: String) {
|
|
||||||
val entry = crypto.encrypt(value)
|
|
||||||
|
|
||||||
context.dataStore.edit { prefs ->
|
|
||||||
prefs[stringPreferencesKey("${key}_encrypted")] = entry.encrypted
|
|
||||||
prefs[stringPreferencesKey("${key}_iv")] = entry.iv
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun get(key: String): String? {
|
|
||||||
val prefs = context.dataStore.data.first()
|
|
||||||
val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null
|
|
||||||
val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null
|
|
||||||
|
|
||||||
return crypto.decrypt(SecretEntry(encrypted, iv))
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun clear(key: String) {
|
|
||||||
context.dataStore.edit { prefs ->
|
|
||||||
prefs.remove(stringPreferencesKey("${key}_encrypted"))
|
|
||||||
prefs.remove(stringPreferencesKey("${key}_iv"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun has(key: String): Boolean {
|
|
||||||
val prefs = context.dataStore.data.first()
|
|
||||||
return prefs[stringPreferencesKey("${key}_encrypted")] != null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -33,6 +33,8 @@ kotlin {
|
|||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
implementation(libs.androidx.datastore)
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||||
implementation("su.reya:nostr-sdk-kmp:0.3.2")
|
implementation("su.reya:nostr-sdk-kmp:0.3.2")
|
||||||
|
|||||||
14
shared/src/commonMain/kotlin/su/reya/coop/AppStorage.kt
Normal file
14
shared/src/commonMain/kotlin/su/reya/coop/AppStorage.kt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package su.reya.coop
|
||||||
|
|
||||||
|
interface AppStorage {
|
||||||
|
// Plain text storage
|
||||||
|
suspend fun get(key: String): String?
|
||||||
|
suspend fun set(key: String, value: String)
|
||||||
|
|
||||||
|
// Encrypted storage
|
||||||
|
suspend fun getSecret(key: String): String?
|
||||||
|
suspend fun setSecret(key: String, value: String)
|
||||||
|
|
||||||
|
suspend fun clear(key: String)
|
||||||
|
suspend fun has(key: String): Boolean
|
||||||
|
}
|
||||||
@@ -10,17 +10,17 @@ fun PublicKey.short(): String {
|
|||||||
val URL_REGEX = Regex("(https?://\\S+)", RegexOption.IGNORE_CASE)
|
val URL_REGEX = Regex("(https?://\\S+)", RegexOption.IGNORE_CASE)
|
||||||
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
|
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
|
||||||
|
|
||||||
fun String.extractUrls(): List<String> {
|
|
||||||
return URL_REGEX.findAll(this).map { it.value }.toList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun String.removeImageUrls(): String {
|
fun String.removeImageUrls(): String {
|
||||||
return URL_REGEX.replace(this) { result ->
|
return URL_REGEX.replace(this) { result ->
|
||||||
if (result.value.isImageUrl()) "" else result.value
|
if (result.value.isImageUrl()) "" else result.value
|
||||||
}.replace(Regex("\\s+"), " ").trim()
|
}.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun String.isImageUrl(): Boolean {
|
fun String.isImageUrl(): Boolean {
|
||||||
val extension = this.substringAfterLast('.', "").lowercase()
|
val extension = this.substringAfterLast('.', "").lowercase()
|
||||||
return extension in imageExtensions
|
return extension in imageExtensions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun String.sanitizeName(): String {
|
||||||
|
return this.replace("\n", " ").replace("\r", " ").trim()
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ data class Profile(
|
|||||||
private val record by lazy { metadata.asRecord() }
|
private val record by lazy { metadata.asRecord() }
|
||||||
|
|
||||||
val name: String
|
val name: String
|
||||||
get() = record.displayName ?: record.name ?: publicKey.short()
|
get() = record.displayName?.sanitizeName() ?: record.name ?: publicKey.short()
|
||||||
|
|
||||||
val picture: String?
|
val picture: String?
|
||||||
get() = record.picture
|
get() = record.picture
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ data class Room(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
|
fun new(rumor: UnsignedEvent, userPubkey: PublicKey, id: Long = rumor.roomId()): Room {
|
||||||
val id = rumor.roomId()
|
|
||||||
val createdAt = rumor.createdAt()
|
val createdAt = rumor.createdAt()
|
||||||
val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content()
|
val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content()
|
||||||
|
|
||||||
@@ -99,11 +98,13 @@ fun Room.uiStateFlow(
|
|||||||
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
|
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
|
||||||
|
|
||||||
if (!subject.isNullOrBlank()) {
|
if (!subject.isNullOrBlank()) {
|
||||||
return flowOf(RoomUiState(name = subject, isGroup = isGroup()))
|
return flowOf(RoomUiState(name = subject.sanitizeName(), isGroup = isGroup()))
|
||||||
}
|
}
|
||||||
|
|
||||||
return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { profiles ->
|
return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { profiles ->
|
||||||
val names = profiles.mapIndexed { i, profile -> profile?.name ?: displayMembers[i].short() }
|
val names = profiles.mapIndexed { i, profile ->
|
||||||
|
profile?.name?.sanitizeName() ?: displayMembers[i].short()
|
||||||
|
}
|
||||||
|
|
||||||
val name = when {
|
val name = when {
|
||||||
isGroup() -> {
|
isGroup() -> {
|
||||||
|
|||||||
@@ -164,38 +164,35 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
try {
|
try {
|
||||||
val userPubkey =
|
val userPubkey =
|
||||||
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||||
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
||||||
|
|
||||||
// Get all DM events
|
// Get all DM events
|
||||||
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
|
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
|
||||||
val events = client?.database()?.query(filter)
|
val events = client?.database()?.query(filter)?.toVec() ?: return null
|
||||||
|
|
||||||
// Collect rooms
|
// Collect rooms
|
||||||
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
|
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
|
||||||
|
|
||||||
events
|
events
|
||||||
?.toVec()
|
.map { UnsignedEvent.fromJson(it.content()) }
|
||||||
?.map { UnsignedEvent.fromJson(it.content()) }
|
.filter { it.tags().publicKeys().isNotEmpty() }
|
||||||
?.filter { it.tags().publicKeys().isNotEmpty() }
|
.forEach { rumor ->
|
||||||
?.forEach { event ->
|
val id = rumor.roomId()
|
||||||
val newRoom = Room.new(rumor = event, userPubkey = userPubkey)
|
val isFromMe = rumor.author() == userPubkey
|
||||||
val existingRoom = roomsMap[newRoom.id]
|
val existing = roomsMap[id]
|
||||||
|
val createdAt = rumor.createdAt()
|
||||||
|
|
||||||
// Check if the room already exists
|
// If the room is new or the current rumor is newer than the existing one
|
||||||
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
|
if (existing == null || createdAt.asSecs() > existing.createdAt.asSecs()) {
|
||||||
val rTag = SingleLetterTag.lowercase(Alphabet.R)
|
// A room is "Ongoing" if it was already marked as such or if the current rumor is from the user
|
||||||
val filter = Filter().kind(kind).pubkey(userPubkey)
|
val isOngoing = (existing?.kind == RoomKind.Ongoing) || isFromMe
|
||||||
.customTag(rTag, newRoom.id.toString())
|
val room = Room.new(rumor = rumor, userPubkey = userPubkey, id = id)
|
||||||
|
roomsMap[id] = if (isOngoing) room.copy(kind = RoomKind.Ongoing) else room
|
||||||
// Determine if it's an ongoing room
|
} else if (isFromMe && existing.kind != RoomKind.Ongoing) {
|
||||||
val isOngoing =
|
// If it's an older rumor but sent by the user, mark the room as Ongoing
|
||||||
client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false
|
roomsMap[id] = existing.copy(kind = RoomKind.Ongoing)
|
||||||
|
|
||||||
// Append room to map
|
|
||||||
roomsMap[newRoom.id] =
|
|
||||||
if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ class Nostr {
|
|||||||
val processedEvent = mutableSetOf<EventId>()
|
val processedEvent = mutableSetOf<EventId>()
|
||||||
val notifications = client?.notifications() ?: return@supervisorScope
|
val notifications = client?.notifications() ?: return@supervisorScope
|
||||||
|
|
||||||
val giftWrapQueue = Channel<Event>(Channel.UNLIMITED)
|
val giftWrapQueue = Channel<Event>(1024)
|
||||||
var processedCount = 0
|
var processedCount = 0
|
||||||
var eoseReceived = false
|
var eoseReceived = false
|
||||||
|
|
||||||
@@ -219,6 +219,7 @@ class Nostr {
|
|||||||
KindStandard.INBOX_RELAYS -> {
|
KindStandard.INBOX_RELAYS -> {
|
||||||
// Get all gift wrap events for the current user
|
// Get all gift wrap events for the current user
|
||||||
if (isSignedByUser(event = event)) {
|
if (isSignedByUser(event = event)) {
|
||||||
|
messages.updateSyncState { it.copy(isSyncing = true) }
|
||||||
messages.getUserMessages(msgRelayList = event)
|
messages.getUserMessages(msgRelayList = event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ class ProfileManager(private val nostr: Nostr) {
|
|||||||
|
|
||||||
client?.sync(filter, relays)
|
client?.sync(filter, relays)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e)
|
println("Failed to sync mutual contacts: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
package su.reya.coop.storage
|
|
||||||
|
|
||||||
interface SecretStorage {
|
|
||||||
suspend fun get(key: String): String?
|
|
||||||
suspend fun set(key: String, value: String)
|
|
||||||
suspend fun clear(key: String)
|
|
||||||
suspend fun has(key: String): Boolean
|
|
||||||
}
|
|
||||||
@@ -7,27 +7,27 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import rust.nostr.sdk.AsyncNostrSigner
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
|
import rust.nostr.sdk.EncryptedSecretKey
|
||||||
import rust.nostr.sdk.Keys
|
import rust.nostr.sdk.Keys
|
||||||
import rust.nostr.sdk.NostrConnect
|
import rust.nostr.sdk.NostrConnect
|
||||||
import rust.nostr.sdk.NostrConnectUri
|
import rust.nostr.sdk.NostrConnectUri
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import su.reya.coop.AppStorage
|
||||||
import su.reya.coop.nostr.ExternalSignerHandler
|
import su.reya.coop.nostr.ExternalSignerHandler
|
||||||
import su.reya.coop.nostr.ExternalSignerProxy
|
import su.reya.coop.nostr.ExternalSignerProxy
|
||||||
import su.reya.coop.nostr.Nostr
|
import su.reya.coop.nostr.Nostr
|
||||||
import su.reya.coop.nostr.SignerPermissions
|
import su.reya.coop.nostr.SignerPermissions
|
||||||
import su.reya.coop.repository.MediaRepository
|
import su.reya.coop.repository.MediaRepository
|
||||||
import su.reya.coop.storage.SecretStorage
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
data class AuthState(
|
data class AuthState(
|
||||||
val isBusy: Boolean = false,
|
|
||||||
val signerRequired: Boolean? = null,
|
val signerRequired: Boolean? = null,
|
||||||
val isNotificationBannerDismissed: Boolean = false,
|
val isNotificationBannerDismissed: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
class AuthViewModel(
|
class AuthViewModel(
|
||||||
private val nostr: Nostr,
|
private val nostr: Nostr,
|
||||||
private val secretStore: SecretStorage,
|
private val storage: AppStorage,
|
||||||
private val externalSignerHandler: ExternalSignerHandler? = null,
|
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
private val mediaRepository = MediaRepository()
|
private val mediaRepository = MediaRepository()
|
||||||
@@ -51,7 +51,7 @@ class AuthViewModel(
|
|||||||
|
|
||||||
private fun checkNotificationBannerDismissedStatus() {
|
private fun checkNotificationBannerDismissedStatus() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val dismissed = secretStore.get(KEY_BANNER_DISMISSED) == "true"
|
val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
|
||||||
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
|
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,8 +59,8 @@ class AuthViewModel(
|
|||||||
private fun login() {
|
private fun login() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val secret = withTimeoutOrNull(3.seconds) {
|
val secret = withTimeoutOrNull(5.seconds) {
|
||||||
secretStore.get(KEY_USER_SIGNER)
|
storage.getSecret(KEY_USER_SIGNER)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (secret == null) {
|
if (secret == null) {
|
||||||
@@ -69,7 +69,7 @@ class AuthViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
val signer = createSigner(secret)
|
val (signer, _) = createSigner(secret)
|
||||||
nostr.setSigner(signer)
|
nostr.setSigner(signer)
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
_state.update { it.copy(signerRequired = false) }
|
_state.update { it.copy(signerRequired = false) }
|
||||||
@@ -87,8 +87,6 @@ class AuthViewModel(
|
|||||||
fun logout(onLogout: () -> Unit = {}) {
|
fun logout(onLogout: () -> Unit = {}) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
_state.update { it.copy(isBusy = true) }
|
|
||||||
|
|
||||||
// Reset the nostr signer and prune the database
|
// Reset the nostr signer and prune the database
|
||||||
nostr.signer.switch(Keys.generate())
|
nostr.signer.switch(Keys.generate())
|
||||||
nostr.prune()
|
nostr.prune()
|
||||||
@@ -96,48 +94,54 @@ class AuthViewModel(
|
|||||||
showError("Logout encountered an error: ${e.message}")
|
showError("Logout encountered an error: ${e.message}")
|
||||||
} finally {
|
} finally {
|
||||||
// Clear credentials from persistent storage
|
// Clear credentials from persistent storage
|
||||||
secretStore.clear(KEY_USER_SIGNER)
|
storage.clear(KEY_USER_SIGNER)
|
||||||
secretStore.clear(KEY_BANNER_DISMISSED)
|
storage.clear(KEY_BANNER_DISMISSED)
|
||||||
|
|
||||||
// Call cleanup callback (e.g. to reset other ViewModels)
|
// Call cleanup callback (e.g. to reset other ViewModels)
|
||||||
onLogout()
|
onLogout()
|
||||||
|
// Reset local states
|
||||||
_state.update { it.copy(isBusy = false, signerRequired = true) }
|
_state.update { it.copy(signerRequired = true) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismissNotificationBanner() {
|
fun dismissNotificationBanner() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
secretStore.set(KEY_BANNER_DISMISSED, "true")
|
storage.set(KEY_BANNER_DISMISSED, "true")
|
||||||
_state.update { it.copy(isNotificationBannerDismissed = true) }
|
_state.update { it.copy(isNotificationBannerDismissed = true) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getOrInitAppKeys(): Keys {
|
private suspend fun getOrInitAppKeys(): Keys {
|
||||||
val secret = secretStore.get(KEY_APP_KEYS)
|
val secret = storage.getSecret(KEY_APP_KEYS)
|
||||||
|
|
||||||
// If app keys are already stored, use them
|
// If app keys are already stored, use them
|
||||||
if (secret != null) {
|
if (secret != null) return Keys.parse(secret)
|
||||||
return Keys.parse(secret)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new app keys and save to the secret storage
|
// Generate new app keys and save to the secret storage
|
||||||
val keys = Keys.generate()
|
val keys = Keys.generate()
|
||||||
secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32())
|
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
|
||||||
|
|
||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun createSigner(secret: String): AsyncNostrSigner {
|
private suspend fun createSigner(
|
||||||
|
secret: String,
|
||||||
|
password: String? = null
|
||||||
|
): Pair<AsyncNostrSigner, String?> {
|
||||||
return when {
|
return when {
|
||||||
secret.startsWith("nsec1") -> Keys.parse(secret)
|
secret.startsWith("nsec1") -> Keys.parse(secret) to null
|
||||||
|
|
||||||
|
secret.startsWith("ncryptsec1") -> {
|
||||||
|
if (password == null) throw IllegalArgumentException("Password is required")
|
||||||
|
val enc = EncryptedSecretKey.fromBech32(secret)
|
||||||
|
val secret = enc.decrypt(password)
|
||||||
|
val keys = Keys(secret)
|
||||||
|
|
||||||
|
keys to keys.secretKey().toBech32()
|
||||||
|
}
|
||||||
|
|
||||||
secret.startsWith("bunker://") -> {
|
secret.startsWith("bunker://") -> {
|
||||||
val appKeys = getOrInitAppKeys()
|
val appKeys = getOrInitAppKeys()
|
||||||
val bunker = NostrConnectUri.parse(secret)
|
val bunker = NostrConnectUri.parse(secret)
|
||||||
val timeout = 50.seconds
|
val timeout = 50.seconds
|
||||||
NostrConnect(uri = bunker, appKeys, timeout, null)
|
NostrConnect(uri = bunker, appKeys, timeout, null) to null
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.startsWith("nip55://") -> {
|
secret.startsWith("nip55://") -> {
|
||||||
@@ -150,77 +154,50 @@ class AuthViewModel(
|
|||||||
val pubkey = PublicKey.parse(parts[1])
|
val pubkey = PublicKey.parse(parts[1])
|
||||||
|
|
||||||
handler.setPackageName(packageName)
|
handler.setPackageName(packageName)
|
||||||
ExternalSignerProxy(handler, pubkey)
|
ExternalSignerProxy(handler, pubkey) to null
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> throw IllegalArgumentException("Invalid secret format")
|
else -> throw IllegalArgumentException("Invalid secret format")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun verifyIdentity(secret: String): PublicKey? {
|
suspend fun importIdentity(secret: String, password: String? = null) {
|
||||||
try {
|
val (signer, decryptedSecret) = createSigner(secret, password)
|
||||||
val signer = createSigner(secret)
|
// Update signer
|
||||||
if (secret.startsWith("bunker://")) {
|
nostr.setSigner(signer)
|
||||||
showError("Please approve the connection.")
|
// Persist the secret in the secret storage
|
||||||
}
|
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
|
||||||
return signer.getPublicKeyAsync()
|
// Update local states
|
||||||
} catch (e: Exception) {
|
_state.update { it.copy(signerRequired = false) }
|
||||||
showError("Error: ${e.message}")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun importIdentity(secret: String) {
|
|
||||||
_state.update { it.copy(isBusy = true) }
|
|
||||||
try {
|
|
||||||
val signer = createSigner(secret)
|
|
||||||
// Update signer
|
|
||||||
nostr.setSigner(signer)
|
|
||||||
// Persist the secret in the secret storage
|
|
||||||
secretStore.set(KEY_USER_SIGNER, secret)
|
|
||||||
// Update local states
|
|
||||||
_state.update { it.copy(signerRequired = false, isBusy = false) }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
_state.update { it.copy(isBusy = false) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun connectExternalSigner() {
|
suspend fun connectExternalSigner() {
|
||||||
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
||||||
_state.update { it.copy(isBusy = true) }
|
|
||||||
try {
|
|
||||||
val permissions = SignerPermissions.toJson(
|
|
||||||
listOf(
|
|
||||||
SignerPermissions.signEvent(0),
|
|
||||||
SignerPermissions.signEvent(3),
|
|
||||||
SignerPermissions.signEvent(10000),
|
|
||||||
SignerPermissions.signEvent(10050),
|
|
||||||
SignerPermissions.signEvent(10063),
|
|
||||||
SignerPermissions.signEvent(22242),
|
|
||||||
SignerPermissions.signEvent(30030),
|
|
||||||
SignerPermissions.signEvent(30315),
|
|
||||||
SignerPermissions.nip44Encrypt(),
|
|
||||||
SignerPermissions.nip44Decrypt(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
|
val permissions = SignerPermissions.toJson(
|
||||||
val signer = ExternalSignerProxy(handler, result.pubkey)
|
listOf(
|
||||||
|
SignerPermissions.signEvent(0),
|
||||||
// Update signer
|
SignerPermissions.signEvent(3),
|
||||||
nostr.setSigner(signer)
|
SignerPermissions.signEvent(10000),
|
||||||
// Store the signer in the secret storage
|
SignerPermissions.signEvent(10050),
|
||||||
secretStore.set(
|
SignerPermissions.signEvent(10063),
|
||||||
KEY_USER_SIGNER,
|
SignerPermissions.signEvent(22242),
|
||||||
"nip55://${result.packageName}/${result.pubkey.toHex()}"
|
SignerPermissions.signEvent(30030),
|
||||||
|
SignerPermissions.signEvent(30315),
|
||||||
|
SignerPermissions.nip44Encrypt(),
|
||||||
|
SignerPermissions.nip44Decrypt(),
|
||||||
)
|
)
|
||||||
// Update local states
|
)
|
||||||
_state.update { it.copy(signerRequired = false, isBusy = false) }
|
|
||||||
} catch (e: Exception) {
|
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
|
||||||
_state.update { it.copy(isBusy = false) }
|
val signer = ExternalSignerProxy(handler, result.pubkey)
|
||||||
showError("Notice: ${e.message}")
|
|
||||||
}
|
// Update signer
|
||||||
|
nostr.setSigner(signer)
|
||||||
|
// Store the signer in the secret storage
|
||||||
|
storage.setSecret(KEY_USER_SIGNER, "nip55://${result.packageName}/${result.pubkey.toHex()}")
|
||||||
|
// Update local states
|
||||||
|
_state.update { it.copy(signerRequired = false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isExternalSignerAvailable(): Boolean {
|
fun isExternalSignerAvailable(): Boolean {
|
||||||
@@ -233,27 +210,16 @@ class AuthViewModel(
|
|||||||
picture: ByteArray?,
|
picture: ByteArray?,
|
||||||
contentType: String? = null
|
contentType: String? = null
|
||||||
) {
|
) {
|
||||||
_state.update { it.copy(isBusy = true) }
|
|
||||||
|
|
||||||
val keys = Keys.generate()
|
val keys = Keys.generate()
|
||||||
val secret = keys.secretKey().toBech32()
|
val secret = keys.secretKey().toBech32()
|
||||||
|
val avatarUrl = picture?.let {
|
||||||
try {
|
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
|
||||||
val avatarUrl = picture?.let {
|
|
||||||
mediaRepository.blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create identity
|
|
||||||
nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)
|
|
||||||
|
|
||||||
// Persist the secret in the secret storage
|
|
||||||
secretStore.set(KEY_USER_SIGNER, secret)
|
|
||||||
|
|
||||||
// Update local states
|
|
||||||
_state.update { it.copy(isBusy = false, signerRequired = false) }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
_state.update { it.copy(isBusy = false) }
|
|
||||||
}
|
}
|
||||||
|
// Create identity
|
||||||
|
nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)
|
||||||
|
// Persist the secret in the secret storage
|
||||||
|
storage.setSecret(KEY_USER_SIGNER, secret)
|
||||||
|
// Update local states
|
||||||
|
_state.update { it.copy(signerRequired = false) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,12 +65,12 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
launch {
|
launch {
|
||||||
nostr.messages.messageSyncState.collect { syncState ->
|
nostr.messages.messageSyncState.collect { syncState ->
|
||||||
// When at least some messages are processed, allow UI to show the list
|
// When at least some messages are processed, allow UI to show the list
|
||||||
if (syncState.processedCount > 0) {
|
if (syncState.processedCount > 0 || !syncState.isSyncing) {
|
||||||
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
|
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh UI every 10 messages OR when sync is fully done
|
// Refresh UI every 100 messages OR when sync is fully done
|
||||||
if (syncState.processedCount % 10 == 0 || !syncState.isSyncing) {
|
if (syncState.processedCount % 100 == 0 || !syncState.isSyncing) {
|
||||||
refreshChatRooms()
|
refreshChatRooms()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,9 +97,6 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
_newEvents.emit(event)
|
_newEvents.emit(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial load of rooms
|
|
||||||
refreshChatRooms()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,9 +146,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
_state.update { currentState ->
|
_state.update { currentState ->
|
||||||
val merged = currentState.rooms.associateBy { it.id }.toMutableMap()
|
val merged = currentState.rooms.associateBy { it.id }.toMutableMap()
|
||||||
// Add or update rooms from the database
|
// Add or update rooms from the database
|
||||||
rooms.forEach { room ->
|
rooms.forEach { room -> merged[room.id] = room }
|
||||||
merged[room.id] = room
|
|
||||||
}
|
|
||||||
// Return as a sorted set to maintain UI consistency
|
// Return as a sorted set to maintain UI consistency
|
||||||
currentState.copy(rooms = merged.values.sortedDescending().toSet())
|
currentState.copy(rooms = merged.values.sortedDescending().toSet())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,9 +71,7 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
|
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
|
||||||
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
||||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||||
|
|
||||||
val isBusy = appState.map { it.isBusy }
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
|
||||||
val isRelayListEmpty = appState.map { it.isRelayListEmpty }
|
val isRelayListEmpty = appState.map { it.isRelayListEmpty }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user