Compare commits
5 Commits
v0.2.4
...
8ea66d1769
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ea66d1769 | |||
| 38b704fe18 | |||
| e0701504aa | |||
| 175c06a1a8 | |||
| c7a646ae73 |
@@ -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.KeyProperties
|
||||
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.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
@@ -10,10 +15,9 @@ import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
data class SecretEntry(
|
||||
val encrypted: String,
|
||||
val iv: String
|
||||
)
|
||||
private val Context.dataStore by preferencesDataStore("secret_store")
|
||||
|
||||
data class SecretEntry(val encrypted: String, val iv: String)
|
||||
|
||||
class SecretCrypto {
|
||||
private val keyAlias = "coop"
|
||||
@@ -21,11 +25,9 @@ class SecretCrypto {
|
||||
private val transformation = "AES/GCM/NoPadding"
|
||||
|
||||
fun encrypt(content: String): SecretEntry {
|
||||
// Initialize cipher
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
||||
|
||||
// Encrypt content
|
||||
val encrypted = cipher.doFinal(content.toByteArray())
|
||||
val iv = cipher.iv
|
||||
|
||||
@@ -39,12 +41,10 @@ class SecretCrypto {
|
||||
val encrypted = Base64.decode(entry.encrypted, Base64.NO_WRAP)
|
||||
val iv = Base64.decode(entry.iv, Base64.NO_WRAP)
|
||||
|
||||
// Initialize cipher
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
val spec = GCMParameterSpec(128, iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
|
||||
|
||||
// Decrypt content
|
||||
val plaintext = cipher.doFinal(encrypted)
|
||||
|
||||
return String(plaintext, StandardCharsets.UTF_8)
|
||||
@@ -54,13 +54,9 @@ class SecretCrypto {
|
||||
val keyStore = KeyStore.getInstance(keyStoreType).apply { load(null) }
|
||||
val existingKey = keyStore.getKey(keyAlias, null)
|
||||
|
||||
// Return existing key if available
|
||||
if (existingKey is SecretKey) return existingKey
|
||||
|
||||
// Construct a new key generator
|
||||
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStoreType)
|
||||
|
||||
// Initialize key generation parameters
|
||||
val spec = KeyGenParameterSpec.Builder(
|
||||
keyAlias,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
@@ -70,9 +66,49 @@ class SecretCrypto {
|
||||
.setKeySize(256)
|
||||
.build()
|
||||
|
||||
// Generate a new key
|
||||
keyGenerator.init(spec)
|
||||
|
||||
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.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import su.reya.coop.coop.storage.SecretStore
|
||||
import su.reya.coop.nostr.NostrManager
|
||||
import su.reya.coop.viewmodel.AuthViewModel
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
@@ -26,15 +25,13 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private val factory by lazy {
|
||||
object : ViewModelProvider.Factory {
|
||||
private val storage = AppStore(this@MainActivity)
|
||||
private val nostrViewModel = NostrViewModel(NostrManager.instance)
|
||||
private val chatViewModel = ChatViewModel(NostrManager.instance)
|
||||
private val androidSigner =
|
||||
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 =
|
||||
AuthViewModel(NostrManager.instance, secretStore, androidSigner)
|
||||
AuthViewModel(NostrManager.instance, storage, androidSigner)
|
||||
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return when {
|
||||
|
||||
@@ -155,7 +155,7 @@ fun HomeScreen() {
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(authState.signerRequired) {
|
||||
chatViewModel.refreshChatRooms()
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -23,6 +24,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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 coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_add_circle
|
||||
@@ -52,6 +55,11 @@ fun ChatInput(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder = { Text("Message") },
|
||||
maxLines = 5,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
imeAction = ImeAction.Default
|
||||
),
|
||||
leadingIcon = {
|
||||
IconButton(onClick = onUpload) {
|
||||
Icon(
|
||||
|
||||
@@ -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.androidx.lifecycle.viewmodelCompose)
|
||||
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-datetime:0.8.0")
|
||||
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)
|
||||
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 {
|
||||
return URL_REGEX.replace(this) { result ->
|
||||
if (result.value.isImageUrl()) "" else result.value
|
||||
}.replace(Regex("\\s+"), " ").trim()
|
||||
}.trim()
|
||||
}
|
||||
|
||||
fun String.isImageUrl(): Boolean {
|
||||
val extension = this.substringAfterLast('.', "").lowercase()
|
||||
return extension in imageExtensions
|
||||
}
|
||||
|
||||
fun String.sanitizeName(): String {
|
||||
return this.replace("\n", " ").replace("\r", " ").trim()
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ data class Profile(
|
||||
private val record by lazy { metadata.asRecord() }
|
||||
|
||||
val name: String
|
||||
get() = record.displayName ?: record.name ?: publicKey.short()
|
||||
get() = record.displayName?.sanitizeName() ?: record.name ?: publicKey.short()
|
||||
|
||||
val picture: String?
|
||||
get() = record.picture
|
||||
|
||||
@@ -41,8 +41,7 @@ data class Room(
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
|
||||
val id = rumor.roomId()
|
||||
fun new(rumor: UnsignedEvent, userPubkey: PublicKey, id: Long = rumor.roomId()): Room {
|
||||
val createdAt = rumor.createdAt()
|
||||
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)
|
||||
|
||||
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 ->
|
||||
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 {
|
||||
isGroup() -> {
|
||||
|
||||
@@ -170,32 +170,29 @@ class MessageManager(private val nostr: Nostr) {
|
||||
|
||||
// Get all DM events
|
||||
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
|
||||
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
|
||||
|
||||
events
|
||||
?.toVec()
|
||||
?.map { UnsignedEvent.fromJson(it.content()) }
|
||||
?.filter { it.tags().publicKeys().isNotEmpty() }
|
||||
?.forEach { event ->
|
||||
val newRoom = Room.new(rumor = event, userPubkey = userPubkey)
|
||||
val existingRoom = roomsMap[newRoom.id]
|
||||
.map { UnsignedEvent.fromJson(it.content()) }
|
||||
.filter { it.tags().publicKeys().isNotEmpty() }
|
||||
.forEach { rumor ->
|
||||
val id = rumor.roomId()
|
||||
val isFromMe = rumor.author() == userPubkey
|
||||
val existing = roomsMap[id]
|
||||
val createdAt = rumor.createdAt()
|
||||
|
||||
// Check if the room already exists
|
||||
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
|
||||
val rTag = SingleLetterTag.lowercase(Alphabet.R)
|
||||
val filter = Filter().kind(kind).pubkey(userPubkey)
|
||||
.customTag(rTag, newRoom.id.toString())
|
||||
|
||||
// Determine if it's an ongoing room
|
||||
val isOngoing =
|
||||
client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false
|
||||
|
||||
// Append room to map
|
||||
roomsMap[newRoom.id] =
|
||||
if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom
|
||||
// If the room is new or the current rumor is newer than the existing one
|
||||
if (existing == null || createdAt.asSecs() > existing.createdAt.asSecs()) {
|
||||
// A room is "Ongoing" if it was already marked as such or if the current rumor is from the user
|
||||
val isOngoing = (existing?.kind == RoomKind.Ongoing) || isFromMe
|
||||
val room = Room.new(rumor = rumor, userPubkey = userPubkey, id = id)
|
||||
roomsMap[id] = if (isOngoing) room.copy(kind = RoomKind.Ongoing) else room
|
||||
} else if (isFromMe && existing.kind != RoomKind.Ongoing) {
|
||||
// If it's an older rumor but sent by the user, mark the room as Ongoing
|
||||
roomsMap[id] = existing.copy(kind = RoomKind.Ongoing)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -12,12 +12,12 @@ import rust.nostr.sdk.Keys
|
||||
import rust.nostr.sdk.NostrConnect
|
||||
import rust.nostr.sdk.NostrConnectUri
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.AppStorage
|
||||
import su.reya.coop.nostr.ExternalSignerHandler
|
||||
import su.reya.coop.nostr.ExternalSignerProxy
|
||||
import su.reya.coop.nostr.Nostr
|
||||
import su.reya.coop.nostr.SignerPermissions
|
||||
import su.reya.coop.repository.MediaRepository
|
||||
import su.reya.coop.storage.SecretStorage
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
data class AuthState(
|
||||
@@ -27,7 +27,7 @@ data class AuthState(
|
||||
|
||||
class AuthViewModel(
|
||||
private val nostr: Nostr,
|
||||
private val secretStore: SecretStorage,
|
||||
private val storage: AppStorage,
|
||||
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||
) : BaseViewModel() {
|
||||
private val mediaRepository = MediaRepository()
|
||||
@@ -51,7 +51,7 @@ class AuthViewModel(
|
||||
|
||||
private fun checkNotificationBannerDismissedStatus() {
|
||||
viewModelScope.launch {
|
||||
val dismissed = secretStore.get(KEY_BANNER_DISMISSED) == "true"
|
||||
val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
|
||||
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ class AuthViewModel(
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val secret = withTimeoutOrNull(5.seconds) {
|
||||
secretStore.get(KEY_USER_SIGNER)
|
||||
storage.getSecret(KEY_USER_SIGNER)
|
||||
}
|
||||
|
||||
if (secret == null) {
|
||||
@@ -94,8 +94,8 @@ class AuthViewModel(
|
||||
showError("Logout encountered an error: ${e.message}")
|
||||
} finally {
|
||||
// Clear credentials from persistent storage
|
||||
secretStore.clear(KEY_USER_SIGNER)
|
||||
secretStore.clear(KEY_BANNER_DISMISSED)
|
||||
storage.clear(KEY_USER_SIGNER)
|
||||
storage.clear(KEY_BANNER_DISMISSED)
|
||||
// Call cleanup callback (e.g. to reset other ViewModels)
|
||||
onLogout()
|
||||
// Reset local states
|
||||
@@ -106,18 +106,18 @@ class AuthViewModel(
|
||||
|
||||
fun dismissNotificationBanner() {
|
||||
viewModelScope.launch {
|
||||
secretStore.set(KEY_BANNER_DISMISSED, "true")
|
||||
storage.set(KEY_BANNER_DISMISSED, "true")
|
||||
_state.update { it.copy(isNotificationBannerDismissed = true) }
|
||||
}
|
||||
}
|
||||
|
||||
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 (secret != null) return Keys.parse(secret)
|
||||
// Generate new app keys and save to the secret storage
|
||||
val keys = Keys.generate()
|
||||
secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32())
|
||||
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
|
||||
return keys
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ class AuthViewModel(
|
||||
// Update signer
|
||||
nostr.setSigner(signer)
|
||||
// Persist the secret in the secret storage
|
||||
secretStore.set(KEY_USER_SIGNER, decryptedSecret ?: secret)
|
||||
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
|
||||
// Update local states
|
||||
_state.update { it.copy(signerRequired = false) }
|
||||
}
|
||||
@@ -195,7 +195,7 @@ class AuthViewModel(
|
||||
// Update signer
|
||||
nostr.setSigner(signer)
|
||||
// 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
|
||||
_state.update { it.copy(signerRequired = false) }
|
||||
}
|
||||
@@ -218,7 +218,7 @@ class AuthViewModel(
|
||||
// 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)
|
||||
storage.setSecret(KEY_USER_SIGNER, secret)
|
||||
// Update local states
|
||||
_state.update { it.copy(signerRequired = false) }
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import su.reya.coop.repository.MediaRepository
|
||||
import su.reya.coop.roomId
|
||||
|
||||
data class ChatState(
|
||||
val rooms: Set<Room> = emptySet(),
|
||||
val rooms: Map<Long, Room> = emptyMap(),
|
||||
val isSyncing: Boolean = false,
|
||||
val isPartialProcessedGiftWrap: Boolean = false,
|
||||
)
|
||||
@@ -48,8 +48,8 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
|
||||
val sentReport = _sentReports.asSharedFlow()
|
||||
|
||||
val chatRooms = state.map { it.rooms }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
|
||||
val chatRooms = state.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
val isSyncing = state.map { it.isSyncing }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
@@ -69,8 +69,8 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
|
||||
}
|
||||
|
||||
// Refresh UI every 10 messages OR when sync is fully done
|
||||
if (syncState.processedCount % 10 == 0 || !syncState.isSyncing) {
|
||||
// Refresh UI every 100 messages OR when sync is fully done
|
||||
if (syncState.processedCount % 100 == 0 || !syncState.isSyncing) {
|
||||
refreshChatRooms()
|
||||
}
|
||||
}
|
||||
@@ -80,16 +80,12 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||
launch {
|
||||
nostr.newEvents.collect { event ->
|
||||
val roomId = event.roomId()
|
||||
val existingRoom = _state.value.rooms.firstOrNull { it.id == roomId }
|
||||
val existingRoom = _state.value.rooms[roomId]
|
||||
|
||||
if (existingRoom == null) {
|
||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
|
||||
val newRoom = Room.new(event, currentUser)
|
||||
_state.update {
|
||||
it.copy(
|
||||
rooms = (it.rooms + newRoom).sortedDescending().toSet()
|
||||
)
|
||||
}
|
||||
_state.update { it.copy(rooms = it.rooms + (newRoom.id to newRoom)) }
|
||||
} else {
|
||||
updateRoomList(roomId, event)
|
||||
}
|
||||
@@ -97,9 +93,6 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||
_newEvents.emit(event)
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load of rooms
|
||||
refreshChatRooms()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +113,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||
|
||||
// Check if the room already exists
|
||||
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 (existingRoom != null) {
|
||||
@@ -131,7 +124,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||
val room = Room.new(rumor, currentUser)
|
||||
|
||||
// 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
|
||||
} catch (e: Exception) {
|
||||
@@ -140,20 +133,16 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||
}
|
||||
|
||||
fun getChatRoom(id: Long): Room? {
|
||||
return _state.value.rooms.firstOrNull { it.id == id }
|
||||
return _state.value.rooms[id]
|
||||
}
|
||||
|
||||
suspend fun refreshChatRooms() {
|
||||
try {
|
||||
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||
_state.update { currentState ->
|
||||
val merged = currentState.rooms.associateBy { it.id }.toMutableMap()
|
||||
// Add or update rooms from the database
|
||||
rooms.forEach { room ->
|
||||
merged[room.id] = room
|
||||
}
|
||||
// Return as a sorted set to maintain UI consistency
|
||||
currentState.copy(rooms = merged.values.sortedDescending().toSet())
|
||||
val newMap = currentState.rooms.toMutableMap()
|
||||
rooms.forEach { room -> newMap[room.id] = room }
|
||||
currentState.copy(rooms = newMap)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
@@ -235,24 +224,19 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||
|
||||
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
|
||||
_state.update { currentState ->
|
||||
val updatedRooms = currentState.rooms.map { room ->
|
||||
if (room.id == roomId) {
|
||||
room.copy(
|
||||
lastMessage = newMessage.content(),
|
||||
createdAt = newMessage.createdAt()
|
||||
)
|
||||
} else {
|
||||
room
|
||||
}
|
||||
}.sortedDescending().toSet()
|
||||
currentState.copy(rooms = updatedRooms)
|
||||
val room = currentState.rooms[roomId] ?: return@update currentState
|
||||
val updatedRoom = room.copy(
|
||||
lastMessage = newMessage.content(),
|
||||
createdAt = newMessage.createdAt()
|
||||
)
|
||||
currentState.copy(rooms = currentState.rooms + (roomId to updatedRoom))
|
||||
}
|
||||
}
|
||||
|
||||
fun resetInternalState() {
|
||||
_state.update {
|
||||
it.copy(
|
||||
rooms = emptySet(),
|
||||
rooms = emptyMap(),
|
||||
isPartialProcessedGiftWrap = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -158,12 +158,14 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||
viewModelScope.launch {
|
||||
// Wait until the client is ready
|
||||
nostr.waitUntilInitialized()
|
||||
val cache = nostr.profiles.getAllCacheMetadata()
|
||||
|
||||
nostr.profiles.getAllCacheMetadata().forEach { (pubkey, metadata) ->
|
||||
// Update the metadata state
|
||||
updateMetadata(pubkey, Profile(pubkey, metadata))
|
||||
// Update seenPublicKeys to avoid duplicate requests
|
||||
seenPublicKeys.add(pubkey)
|
||||
profilesMutex.withLock {
|
||||
cache.forEach { (pubkey, metadata) ->
|
||||
val profile = Profile(pubkey, metadata)
|
||||
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
|
||||
seenPublicKeys.add(pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,11 +198,9 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateMetadata(pubkey: PublicKey, profile: Profile) {
|
||||
viewModelScope.launch {
|
||||
profilesMutex.withLock {
|
||||
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
|
||||
}
|
||||
private suspend fun updateMetadata(pubkey: PublicKey, profile: Profile) {
|
||||
profilesMutex.withLock {
|
||||
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user