7 Commits

Author SHA1 Message Date
9a8d3c06fa update 2026-07-10 10:06:54 +07:00
5970acc88b clean up view models 2026-07-10 08:41:44 +07:00
8ea66d1769 refactor 2026-07-09 17:28:37 +07:00
38b704fe18 improve initial loading 2026-07-09 16:44:11 +07:00
e0701504aa remove unnecessary query call 2026-07-09 16:12:38 +07:00
175c06a1a8 chore: refactor the internal storage layer (#40)
Reviewed-on: #40
2026-07-09 07:53:22 +00:00
c7a646ae73 chore: improve line break handling (#39)
Reviewed-on: #39
2026-07-09 03:48:42 +00:00
23 changed files with 367 additions and 339 deletions

View File

@@ -34,7 +34,7 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import su.reya.coop.repository.ErrorRepository import kotlinx.coroutines.launch
import su.reya.coop.screens.chat.ChatScreen import su.reya.coop.screens.chat.ChatScreen
import su.reya.coop.screens.ContactListScreen import su.reya.coop.screens.ContactListScreen
import su.reya.coop.screens.HomeScreen import su.reya.coop.screens.HomeScreen
@@ -118,8 +118,20 @@ fun App(
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
ErrorRepository.errors.collect { message -> launch {
snackbarHostState.showSnackbar(message) authViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
launch {
chatViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
launch {
nostrViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
} }
} }

View File

@@ -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"))
}
}

View File

@@ -12,8 +12,8 @@ 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.repository.MediaRepository
import su.reya.coop.viewmodel.AuthViewModel import su.reya.coop.viewmodel.AuthViewModel
import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.NostrViewModel import su.reya.coop.viewmodel.NostrViewModel
@@ -26,15 +26,14 @@ 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 mediaRepository = MediaRepository()
private val nostrViewModel = NostrViewModel(NostrManager.instance, mediaRepository)
private val chatViewModel = ChatViewModel(NostrManager.instance, mediaRepository)
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, mediaRepository, androidSigner)
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when { return when {

View File

@@ -155,7 +155,7 @@ fun HomeScreen() {
onPauseOrDispose { } onPauseOrDispose { }
} }
LaunchedEffect(Unit) { LaunchedEffect(authState.signerRequired) {
chatViewModel.refreshChatRooms() chatViewModel.refreshChatRooms()
} }

View File

@@ -34,7 +34,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
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
@@ -44,10 +43,10 @@ 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.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.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
@@ -65,12 +64,12 @@ fun ImportScreen() {
val qrScanResult = LocalScanResult.current val qrScanResult = LocalScanResult.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val authViewModel = LocalAuthViewModel.current val authViewModel = LocalAuthViewModel.current
val scope = rememberCoroutineScope()
val authState by authViewModel.state.collectAsStateWithLifecycle()
var secret by remember { mutableStateOf("") } var secret by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
var requirePassword by remember { mutableStateOf(false) } var requirePassword by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(false) }
LaunchedEffect(qrScanResult.content) { LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result -> qrScanResult.content?.let { result ->
@@ -98,6 +97,20 @@ fun ImportScreen() {
} }
} }
// Navigate to Home on successful import (signerRequired becomes false)
LaunchedEffect(authState.signerRequired) {
if (authState.signerRequired == false) {
navigator.navigate(Screen.Home)
}
}
// Show import errors via snackbar
LaunchedEffect(authState.importError) {
authState.importError?.let {
snackbarHostState.showSnackbar(it)
}
}
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -164,7 +177,7 @@ fun ImportScreen() {
BasicTextField( BasicTextField(
value = secret, value = secret,
onValueChange = { secret = it }, onValueChange = { secret = it },
enabled = !loading, enabled = !authState.isImporting,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
@@ -209,7 +222,7 @@ fun ImportScreen() {
BasicTextField( BasicTextField(
value = password, value = password,
onValueChange = { password = it }, onValueChange = { password = it },
enabled = !loading && requirePassword, enabled = !authState.isImporting && requirePassword,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
@@ -237,25 +250,14 @@ fun ImportScreen() {
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
Button( Button(
onClick = { onClick = {
scope.launch { authViewModel.importIdentity(secret, password)
loading = true
try {
// Import the identity
authViewModel.importIdentity(secret, password)
// Navigate to the home screen
navigator.navigate(Screen.Home)
} catch (e: Exception) {
snackbarHostState.showSnackbar(e.message ?: "Error")
loading = false
}
}
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight), .height(ButtonDefaults.MediumContainerHeight),
enabled = secret.isNotBlank() && !loading, enabled = secret.isNotBlank() && !authState.isImporting,
) { ) {
if (loading) { if (authState.isImporting) {
LoadingIndicator() LoadingIndicator()
} else { } else {
Text( Text(

View File

@@ -24,6 +24,8 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@@ -44,6 +46,7 @@ import androidx.core.net.toUri
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.coop import coop.composeapp.generated.resources.coop
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalAuthViewModel import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
@@ -58,8 +61,24 @@ fun OnboardingScreen() {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val authViewModel = LocalAuthViewModel.current val authViewModel = LocalAuthViewModel.current
val authState by authViewModel.state.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Navigate to Home on successful external signer connection
LaunchedEffect(authState.signerRequired) {
if (authState.signerRequired == false) {
navigator.navigate(Screen.Home)
}
}
// Show connection errors
LaunchedEffect(authState.importError) {
authState.importError?.let {
snackbarHostState.showSnackbar(it)
}
}
val logoPainter = painterResource(Res.drawable.coop) val logoPainter = painterResource(Res.drawable.coop)
val expressiveFont = getExpressiveFontFamily() val expressiveFont = getExpressiveFontFamily()
@@ -157,18 +176,12 @@ fun OnboardingScreen() {
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
FilledTonalButton( FilledTonalButton(
onClick = { onClick = {
scope.launch { if (authViewModel.isExternalSignerAvailable()) {
if (authViewModel.isExternalSignerAvailable()) { // Connect to the external signer
try { // TODO: show all available signers?
// Connect to the external signer authViewModel.connectExternalSigner()
// TODO: show all available signers? } else {
authViewModel.connectExternalSigner() scope.launch {
// Navigate to the home screen
navigator.navigate(Screen.Home)
} catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) }
}
} else {
val result = snackbarHostState.showSnackbar( val result = snackbarHostState.showSnackbar(
message = "External signer not installed. Please install Amber or alternatives.", message = "External signer not installed. Please install Amber or alternatives.",
actionLabel = "Install", actionLabel = "Install",

View File

@@ -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(

View File

@@ -120,20 +120,16 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
val sendFile = { uri: Uri -> val sendFile = { uri: Uri ->
scope.launch { scope.launch {
try { // Read file on IO dispatcher
// Read file val file = withContext(Dispatchers.IO) {
val file = withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}
// Parse the file content type
val type = context.contentResolver.getType(uri)
// Send message
chatViewModel.sendFileMessage(id, file, type)
} catch (e: Exception) {
snackbarHostState.showSnackbar("Error: ${e.message}")
} }
// Parse the file content type
val type = context.contentResolver.getType(uri)
// Send message (handles errors internally via ViewModel)
chatViewModel.sendFileMessage(id, file, type)
} }
} }

View File

@@ -69,7 +69,7 @@ fun ProfileEditor(
initialBio: String = "", initialBio: String = "",
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL) initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
onBack: () -> Unit, onBack: () -> Unit,
onConfirm: suspend (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit onConfirm: (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

View File

@@ -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
}
}

View File

@@ -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")

View 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
}

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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() -> {

View File

@@ -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
} }
} }

View File

@@ -29,16 +29,14 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
/** /**
* Switch to a new signer. * Switch to a new signer.
*/ */
suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock { suspend fun switch(newSigner: AsyncNostrSigner) {
val pubkey = try { val pubkey = withTimeoutOrNull(20.seconds) { newSigner.getPublicKeyAsync() }
withTimeoutOrNull(20.seconds) { ?: throw IllegalStateException("Failed to get public key from signer")
newSigner.getPublicKeyAsync()
} mutex.withLock {
} catch (e: Exception) { signer = newSigner
throw IllegalStateException("Failed to get public key from signer", e) _publicKeyFlow.value = pubkey
} }
signer = newSigner
_publicKeyFlow.value = pubkey
} }
override suspend fun getPublicKeyAsync(): PublicKey? { override suspend fun getPublicKeyAsync(): PublicKey? {

View File

@@ -1,13 +0,0 @@
package su.reya.coop.repository
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
object ErrorRepository {
private val _errors = Channel<String>(Channel.BUFFERED)
val errors = _errors.receiveAsFlow()
fun showError(message: String) {
_errors.trySend(message)
}
}

View File

@@ -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
}

View File

@@ -12,26 +12,27 @@ 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 signerRequired: Boolean? = null, val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false, val isNotificationBannerDismissed: Boolean = false,
val isImporting: Boolean = false,
val importError: String? = null,
) )
class AuthViewModel( class AuthViewModel(
private val nostr: Nostr, private val nostr: Nostr,
private val secretStore: SecretStorage, private val storage: AppStorage,
private val mediaRepository: MediaRepository,
private val externalSignerHandler: ExternalSignerHandler? = null, private val externalSignerHandler: ExternalSignerHandler? = null,
) : BaseViewModel() { ) : BaseViewModel() {
private val mediaRepository = MediaRepository()
companion object { companion object {
private const val KEY_USER_SIGNER = "user_signer" private const val KEY_USER_SIGNER = "user_signer"
private const val KEY_APP_KEYS = "app_keys" private const val KEY_APP_KEYS = "app_keys"
@@ -51,7 +52,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) }
} }
} }
@@ -60,7 +61,7 @@ class AuthViewModel(
viewModelScope.launch { viewModelScope.launch {
try { try {
val secret = withTimeoutOrNull(5.seconds) { val secret = withTimeoutOrNull(5.seconds) {
secretStore.get(KEY_USER_SIGNER) storage.getSecret(KEY_USER_SIGNER)
} }
if (secret == null) { if (secret == null) {
@@ -94,8 +95,8 @@ 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 // Reset local states
@@ -106,18 +107,18 @@ class AuthViewModel(
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) return Keys.parse(secret) if (secret != null) 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
} }
@@ -161,65 +162,98 @@ class AuthViewModel(
} }
} }
suspend fun importIdentity(secret: String, password: String? = null) { fun importIdentity(secret: String, password: String? = null) {
val (signer, decryptedSecret) = createSigner(secret, password) viewModelScope.launch {
// Update signer _state.update { it.copy(isImporting = true, importError = null) }
nostr.setSigner(signer) try {
// Persist the secret in the secret storage val (signer, decryptedSecret) = createSigner(secret, password)
secretStore.set(KEY_USER_SIGNER, decryptedSecret ?: secret) // Update signer
// Update local states nostr.setSigner(signer)
_state.update { it.copy(signerRequired = false) } // Persist the secret in the secret storage
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
// Update local states
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("Import failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
} }
suspend fun connectExternalSigner() { fun connectExternalSigner() {
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") viewModelScope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val handler = externalSignerHandler
?: throw IllegalStateException("Signer not available")
val permissions = SignerPermissions.toJson( val permissions = SignerPermissions.toJson(
listOf( listOf(
SignerPermissions.signEvent(0), SignerPermissions.signEvent(0),
SignerPermissions.signEvent(3), SignerPermissions.signEvent(3),
SignerPermissions.signEvent(10000), SignerPermissions.signEvent(10000),
SignerPermissions.signEvent(10050), SignerPermissions.signEvent(10050),
SignerPermissions.signEvent(10063), SignerPermissions.signEvent(10063),
SignerPermissions.signEvent(22242), SignerPermissions.signEvent(22242),
SignerPermissions.signEvent(30030), SignerPermissions.signEvent(30030),
SignerPermissions.signEvent(30315), SignerPermissions.signEvent(30315),
SignerPermissions.nip44Encrypt(), SignerPermissions.nip44Encrypt(),
SignerPermissions.nip44Decrypt(), SignerPermissions.nip44Decrypt(),
) )
) )
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected") val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
val signer = ExternalSignerProxy(handler, result.pubkey) val signer = ExternalSignerProxy(handler, result.pubkey)
// Update signer // Update signer
nostr.setSigner(signer) nostr.setSigner(signer)
// Store the signer in the secret storage // Store the signer in the secret storage
secretStore.set(KEY_USER_SIGNER, "nip55://${result.packageName}/${result.pubkey.toHex()}") storage.setSecret(
// Update local states KEY_USER_SIGNER,
_state.update { it.copy(signerRequired = false) } "nip55://${result.packageName}/${result.pubkey.toHex()}"
)
// Update local states
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("External signer connection failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
} }
fun isExternalSignerAvailable(): Boolean { fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true return externalSignerHandler?.isAvailable() == true
} }
suspend fun createIdentity( fun createIdentity(
name: String, name: String,
bio: String?, bio: String?,
picture: ByteArray?, picture: ByteArray?,
contentType: String? = null contentType: String? = null
) { ) {
val keys = Keys.generate() viewModelScope.launch {
val secret = keys.secretKey().toBech32() _state.update { it.copy(isImporting = true, importError = null) }
val avatarUrl = picture?.let { try {
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg") val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
}
// 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, isImporting = false) }
} catch (e: Exception) {
showError("Identity creation failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
} }
// 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(signerRequired = false) }
} }
} }

View File

@@ -1,10 +1,14 @@
package su.reya.coop.viewmodel package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import su.reya.coop.repository.ErrorRepository import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
abstract class BaseViewModel : ViewModel() { abstract class BaseViewModel : ViewModel() {
private val _errorEvents = MutableSharedFlow<String>(extraBufferCapacity = 10)
val errorEvents = _errorEvents.asSharedFlow()
protected fun showError(message: String) { protected fun showError(message: String) {
ErrorRepository.showError(message) _errorEvents.tryEmit(message)
} }
} }

View File

@@ -1,6 +1,7 @@
package su.reya.coop.viewmodel package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -24,14 +25,15 @@ import su.reya.coop.repository.MediaRepository
import su.reya.coop.roomId import su.reya.coop.roomId
data class ChatState( data class ChatState(
val rooms: Set<Room> = emptySet(), val rooms: Map<Long, Room> = emptyMap(),
val isSyncing: Boolean = false, val isSyncing: Boolean = false,
val isPartialProcessedGiftWrap: Boolean = false, val isPartialProcessedGiftWrap: Boolean = false,
) )
class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { class ChatViewModel(
private val mediaRepository = MediaRepository() private val nostr: Nostr,
private val mediaRepository: MediaRepository,
) : BaseViewModel() {
private val _state = MutableStateFlow(ChatState()) private val _state = MutableStateFlow(ChatState())
val state = combine( val state = combine(
_state, _state,
@@ -48,8 +50,8 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>() private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
val sentReport = _sentReports.asSharedFlow() val sentReport = _sentReports.asSharedFlow()
val chatRooms = state.map { it.rooms } val chatRooms = state.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val isSyncing = state.map { it.isSyncing } val isSyncing = state.map { it.isSyncing }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
@@ -69,8 +71,8 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
_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()
} }
} }
@@ -80,16 +82,12 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
launch { launch {
nostr.newEvents.collect { event -> nostr.newEvents.collect { event ->
val roomId = event.roomId() val roomId = event.roomId()
val existingRoom = _state.value.rooms.firstOrNull { it.id == roomId } val existingRoom = _state.value.rooms[roomId]
if (existingRoom == null) { if (existingRoom == null) {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
val newRoom = Room.new(event, currentUser) val newRoom = Room.new(event, currentUser)
_state.update { _state.update { it.copy(rooms = it.rooms + (newRoom.id to newRoom)) }
it.copy(
rooms = (it.rooms + newRoom).sortedDescending().toSet()
)
}
} else { } else {
updateRoomList(roomId, event) updateRoomList(roomId, event)
} }
@@ -97,9 +95,6 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
_newEvents.emit(event) _newEvents.emit(event)
} }
} }
// Initial load of rooms
refreshChatRooms()
} }
} }
@@ -120,7 +115,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
// Check if the room already exists // Check if the room already exists
val id = rumor.roomId() val id = rumor.roomId()
val existingRoom = _state.value.rooms.firstOrNull { it.id == id } val existingRoom = _state.value.rooms[id]
// If the room already exists, return its ID // If the room already exists, return its ID
if (existingRoom != null) { if (existingRoom != null) {
@@ -131,7 +126,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
val room = Room.new(rumor, currentUser) val room = Room.new(rumor, currentUser)
// Update the chat rooms state // Update the chat rooms state
_state.update { it.copy(rooms = (it.rooms + room).sortedDescending().toSet()) } _state.update { it.copy(rooms = it.rooms + (room.id to room)) }
return room.id return room.id
} catch (e: Exception) { } catch (e: Exception) {
@@ -140,21 +135,19 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
} }
fun getChatRoom(id: Long): Room? { fun getChatRoom(id: Long): Room? {
return _state.value.rooms.firstOrNull { it.id == id } return _state.value.rooms[id]
} }
suspend fun refreshChatRooms() { suspend fun refreshChatRooms() {
try { try {
val rooms = nostr.messages.getChatRooms() ?: emptySet() val rooms = nostr.messages.getChatRooms() ?: emptySet()
_state.update { currentState -> _state.update { currentState ->
val merged = currentState.rooms.associateBy { it.id }.toMutableMap() val newMap = currentState.rooms.toMutableMap()
// Add or update rooms from the database rooms.forEach { room -> newMap[room.id] = room }
rooms.forEach { room -> currentState.copy(rooms = newMap)
merged[room.id] = room
}
// Return as a sorted set to maintain UI consistency
currentState.copy(rooms = merged.values.sortedDescending().toSet())
} }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} }
@@ -186,6 +179,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) { fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
if (message.isEmpty()) { if (message.isEmpty()) {
showError("Message cannot be empty") showError("Message cannot be empty")
return
} }
viewModelScope.launch { viewModelScope.launch {
try { try {
@@ -206,7 +200,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
} }
} }
suspend fun sendFileMessage( fun sendFileMessage(
roomId: Long, roomId: Long,
file: ByteArray?, file: ByteArray?,
contentType: String? = "image/jpeg", contentType: String? = "image/jpeg",
@@ -214,11 +208,13 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
) { ) {
if (file == null) return if (file == null) return
try { viewModelScope.launch {
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType) try {
if (uri != null) sendMessage(roomId, uri, replies) val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
} catch (e: Exception) { if (uri != null) sendMessage(roomId, uri, replies)
throw IllegalArgumentException("Error: ${e.message}") } catch (e: Exception) {
showError("File upload failed: ${e.message}")
}
} }
} }
@@ -235,24 +231,19 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) { private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
_state.update { currentState -> _state.update { currentState ->
val updatedRooms = currentState.rooms.map { room -> val room = currentState.rooms[roomId] ?: return@update currentState
if (room.id == roomId) { val updatedRoom = room.copy(
room.copy( lastMessage = newMessage.content(),
lastMessage = newMessage.content(), createdAt = newMessage.createdAt()
createdAt = newMessage.createdAt() )
) currentState.copy(rooms = currentState.rooms + (roomId to updatedRoom))
} else {
room
}
}.sortedDescending().toSet()
currentState.copy(rooms = updatedRooms)
} }
} }
fun resetInternalState() { fun resetInternalState() {
_state.update { _state.update {
it.copy( it.copy(
rooms = emptySet(), rooms = emptyMap(),
isPartialProcessedGiftWrap = false, isPartialProcessedGiftWrap = false,
) )
} }

View File

@@ -2,7 +2,6 @@ package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -10,11 +9,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -39,30 +36,12 @@ data class NostrAppState(
val isRelayListEmpty: Boolean = false, val isRelayListEmpty: Boolean = false,
) )
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { class NostrViewModel(
private val mediaRepository = MediaRepository() private val nostr: Nostr,
private val mediaRepository: MediaRepository,
private val alwaysRunTasks = flow { ) : BaseViewModel() {
coroutineScope {
val observerJob = launch { runObserver() }
val batchingJob = launch { runMetadataBatching() }
try {
emit(Unit)
awaitCancellation()
} finally {
observerJob.cancel()
batchingJob.cancel()
}
}
}
private val _appState = MutableStateFlow(NostrAppState()) private val _appState = MutableStateFlow(NostrAppState())
val appState: StateFlow<NostrAppState> = val appState: StateFlow<NostrAppState> = _appState.asStateFlow()
combine(_appState, alwaysRunTasks) { state, _ -> state }.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = NostrAppState()
)
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet()) private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList = _contactList.asStateFlow() val contactList = _contactList.asStateFlow()
@@ -71,7 +50,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 isRelayListEmpty = appState.map { it.isRelayListEmpty } val isRelayListEmpty = appState.map { it.isRelayListEmpty }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
@@ -83,6 +62,10 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
init { init {
// Launch continuous background observers
viewModelScope.launch { runObserver() }
viewModelScope.launch { runMetadataBatching() }
// Automatically reconnect bootstrap relays // Automatically reconnect bootstrap relays
reconnect() reconnect()
@@ -116,7 +99,7 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
} }
} }
private suspend fun runMetadataBatching() = coroutineScope { private suspend fun runMetadataBatching() {
// Wait until the client is ready // Wait until the client is ready
nostr.waitUntilInitialized() nostr.waitUntilInitialized()
@@ -158,12 +141,14 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
viewModelScope.launch { viewModelScope.launch {
// Wait until the client is ready // Wait until the client is ready
nostr.waitUntilInitialized() nostr.waitUntilInitialized()
val cache = nostr.profiles.getAllCacheMetadata()
nostr.profiles.getAllCacheMetadata().forEach { (pubkey, metadata) -> profilesMutex.withLock {
// Update the metadata state cache.forEach { (pubkey, metadata) ->
updateMetadata(pubkey, Profile(pubkey, metadata)) val profile = Profile(pubkey, metadata)
// Update seenPublicKeys to avoid duplicate requests profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
seenPublicKeys.add(pubkey) seenPublicKeys.add(pubkey)
}
} }
} }
} }
@@ -190,17 +175,13 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
private fun requestMetadata(pubkey: PublicKey) { private fun requestMetadata(pubkey: PublicKey) {
if (seenPublicKeys.add(pubkey)) { if (seenPublicKeys.add(pubkey)) {
viewModelScope.launch { metadataRequestChannel.trySend(pubkey)
metadataRequestChannel.send(pubkey)
}
} }
} }
private fun updateMetadata(pubkey: PublicKey, profile: Profile) { private suspend fun updateMetadata(pubkey: PublicKey, profile: Profile) {
viewModelScope.launch { profilesMutex.withLock {
profilesMutex.withLock { profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
}
} }
} }
@@ -224,32 +205,36 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
_appState.update { it.copy(isRelayListEmpty = false) } _appState.update { it.copy(isRelayListEmpty = false) }
} }
suspend fun updateProfile( fun updateProfile(
name: String? = null, name: String? = null,
bio: String? = null, bio: String? = null,
picture: ByteArray? = null, picture: ByteArray? = null,
contentType: String? = null contentType: String? = null
) { ) {
_appState.update { it.copy(isBusy = true) } viewModelScope.launch {
try { _appState.update { it.copy(isBusy = true) }
val avatarUrl = try {
picture?.let { val avatarUrl =
mediaRepository.blossomUpload( picture?.let {
nostr.signer.get(), mediaRepository.blossomUpload(
it, nostr.signer.get(),
contentType ?: "image/jpeg" it,
) contentType ?: "image/jpeg"
} )
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl) }
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
val currentUser =
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
// Update the metadata state after successfully published // Update the metadata state after successfully published
updateMetadata(currentUser, Profile(currentUser, newMetadata)) updateMetadata(currentUser, Profile(currentUser, newMetadata))
// Update local state // Update local state
_appState.update { it.copy(isBusy = false) } _appState.update { it.copy(isBusy = false) }
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
_appState.update { it.copy(isBusy = false) }
}
} }
} }