4 Commits

Author SHA1 Message Date
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
14 changed files with 126 additions and 127 deletions

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,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 {

View File

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

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

@@ -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,7 +10,7 @@ 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

@@ -170,32 +170,29 @@ class MessageManager(private val nostr: Nostr) {
// 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

@@ -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,12 +12,12 @@ 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(
@@ -27,7 +27,7 @@ data class AuthState(
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) }
} }
} }
@@ -60,7 +60,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 +94,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 +106,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
} }
@@ -166,7 +166,7 @@ class AuthViewModel(
// Update signer // Update signer
nostr.setSigner(signer) nostr.setSigner(signer)
// Persist the secret in the secret storage // Persist the secret in the secret storage
secretStore.set(KEY_USER_SIGNER, decryptedSecret ?: secret) storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
// Update local states // Update local states
_state.update { it.copy(signerRequired = false) } _state.update { it.copy(signerRequired = false) }
} }
@@ -195,7 +195,7 @@ class AuthViewModel(
// 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(KEY_USER_SIGNER, "nip55://${result.packageName}/${result.pubkey.toHex()}")
// Update local states // Update local states
_state.update { it.copy(signerRequired = false) } _state.update { it.copy(signerRequired = false) }
} }
@@ -218,7 +218,7 @@ class AuthViewModel(
// Create identity // Create identity
nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl) nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)
// Persist the secret in the secret storage // Persist the secret in the secret storage
secretStore.set(KEY_USER_SIGNER, secret) storage.setSecret(KEY_USER_SIGNER, secret)
// Update local states // Update local states
_state.update { it.copy(signerRequired = false) } _state.update { it.copy(signerRequired = false) }
} }

View File

@@ -69,8 +69,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()
} }
} }
@@ -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())
} }