This commit is contained in:
2026-07-02 16:09:53 +07:00
parent 771441e97e
commit 785afbca08
7 changed files with 99 additions and 82 deletions

View File

@@ -34,6 +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 su.reya.coop.screens.ChatScreen import su.reya.coop.screens.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
@@ -117,7 +118,7 @@ fun App(
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
ErrorManager.errors.collect { message -> ErrorRepository.errors.collect { message ->
snackbarHostState.showSnackbar(message) snackbarHostState.showSnackbar(message)
} }
} }

View File

@@ -1,9 +1,9 @@
package su.reya.coop package su.reya.coop.repository
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
object ErrorManager { object ErrorRepository {
private val _errors = Channel<String>(Channel.BUFFERED) private val _errors = Channel<String>(Channel.BUFFERED)
val errors = _errors.receiveAsFlow() val errors = _errors.receiveAsFlow()

View File

@@ -0,0 +1,39 @@
package su.reya.coop.repository
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import rust.nostr.sdk.AsyncNostrSigner
import su.reya.coop.blossom.BlossomClient
class MediaRepository {
private val httpClient = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
})
}
}
suspend fun blossomUpload(
signer: AsyncNostrSigner,
file: ByteArray,
contentType: String? = "image/jpeg"
): String? {
return try {
val blossom = BlossomClient(url = "https://blossom.band", client = httpClient)
val descriptor = blossom.upload(
file = file,
contentType = contentType,
signer = signer,
)
descriptor?.url
} catch (e: Exception) {
println("Upload failed: ${e.message}")
null
}
}
}

View File

@@ -15,6 +15,7 @@ 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.storage.SecretStorage import su.reya.coop.storage.SecretStorage
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -29,6 +30,8 @@ class AuthViewModel(
private val secretStore: SecretStorage, private val secretStore: SecretStorage,
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"
@@ -236,8 +239,9 @@ class AuthViewModel(
val secret = keys.secretKey().toBech32() val secret = keys.secretKey().toBech32()
try { try {
val avatarUrl = val avatarUrl = picture?.let {
picture?.let { blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg") } mediaRepository.blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg")
}
// 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)

View File

@@ -1,47 +1,10 @@
package su.reya.coop.viewmodel package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import io.ktor.client.HttpClient import su.reya.coop.repository.ErrorRepository
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import rust.nostr.sdk.AsyncNostrSigner
import su.reya.coop.ErrorManager
import su.reya.coop.blossom.BlossomClient
abstract class BaseViewModel : ViewModel() { abstract class BaseViewModel : ViewModel() {
protected val httpClient by lazy {
HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
})
}
}
}
protected fun showError(message: String) { protected fun showError(message: String) {
ErrorManager.showError(message) ErrorRepository.showError(message)
}
protected suspend fun blossomUpload(
signer: AsyncNostrSigner,
file: ByteArray,
contentType: String? = "image/jpeg"
): String? {
return try {
val blossom = BlossomClient(url = "https://blossom.band", client = httpClient)
val descriptor = blossom.upload(
file = file,
contentType = contentType,
signer = signer,
)
descriptor?.url
} catch (e: Exception) {
showError("Upload failed: ${e.message}")
null
}
} }
} }

View File

@@ -5,7 +5,7 @@ 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
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -20,19 +20,27 @@ import rust.nostr.sdk.Tag
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.Room import su.reya.coop.Room
import su.reya.coop.nostr.Nostr import su.reya.coop.nostr.Nostr
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 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 nostr: Nostr) : BaseViewModel() {
private val _state = MutableStateFlow(ChatState()) private val mediaRepository = MediaRepository()
val state = _state.asStateFlow()
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet()) private val _state = MutableStateFlow(ChatState())
val chatRooms = _chatRooms.asStateFlow() val state = combine(
_state,
nostr.messages.messageSyncState
) { local, state -> local.copy(isSyncing = state.isSyncing) }.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
ChatState()
)
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100) private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow() val newEvents = _newEvents.asSharedFlow()
@@ -40,10 +48,13 @@ 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 isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } val chatRooms = state.map { it.rooms }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
val isSyncing = state.map { it.isSyncing }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val isPartialProcessedGiftWrap = _state.map { it.isPartialProcessedGiftWrap } val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
init { init {
@@ -52,14 +63,14 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
// Observe message sync progress // Observe message sync progress
launch { launch {
nostr.messages.messageSyncState.collect { state -> nostr.messages.messageSyncState.collect { syncState ->
// When at least some messages are processed, allow UI to show the list // When at least some messages are processed, allow UI to show the list
if (state.processedCount > 0) { if (syncState.processedCount > 0) {
_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 10 messages OR when sync is fully done
if (state.processedCount % 10 == 0 || !state.isSyncing) { if (syncState.processedCount % 10 == 0 || !syncState.isSyncing) {
refreshChatRooms() refreshChatRooms()
} }
} }
@@ -69,12 +80,16 @@ 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 = _chatRooms.value.firstOrNull { it.id == roomId } val existingRoom = _state.value.rooms.firstOrNull { it.id == 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)
_chatRooms.update { (it + newRoom).sortedDescending().toSet() } _state.update {
it.copy(
rooms = (it.rooms + newRoom).sortedDescending().toSet()
)
}
} else { } else {
updateRoomList(roomId, event) updateRoomList(roomId, event)
} }
@@ -105,7 +120,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 = _chatRooms.value.firstOrNull { it.id == id } val existingRoom = _state.value.rooms.firstOrNull { it.id == id }
// If the room already exists, return its ID // If the room already exists, return its ID
if (existingRoom != null) { if (existingRoom != null) {
@@ -116,9 +131,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
_chatRooms.update { currentRooms -> _state.update { it.copy(rooms = (it.rooms + room).sortedDescending().toSet()) }
(currentRooms + room).sortedDescending().toSet()
}
return room.id return room.id
} catch (e: Exception) { } catch (e: Exception) {
@@ -127,20 +140,20 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
} }
fun getChatRoom(id: Long): Room? { fun getChatRoom(id: Long): Room? {
return chatRooms.value.firstOrNull { it.id == id } return _state.value.rooms.firstOrNull { it.id == id }
} }
suspend fun refreshChatRooms() { suspend fun refreshChatRooms() {
try { try {
val rooms = nostr.messages.getChatRooms() ?: emptySet() val rooms = nostr.messages.getChatRooms() ?: emptySet()
_chatRooms.update { currentRooms -> _state.update { currentState ->
val merged = currentRooms.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
merged.values.sortedDescending().toSet() currentState.copy(rooms = merged.values.sortedDescending().toSet())
} }
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
@@ -202,7 +215,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
if (file == null) return if (file == null) return
try { try {
val uri = blossomUpload(nostr.signer.get(), file, contentType) val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
if (uri != null) sendMessage(roomId, uri, replies) if (uri != null) sendMessage(roomId, uri, replies)
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalArgumentException("Error: ${e.message}") throw IllegalArgumentException("Error: ${e.message}")
@@ -221,8 +234,8 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
} }
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) { private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
_chatRooms.update { currentRooms -> _state.update { currentState ->
currentRooms.map { room -> val updatedRooms = currentState.rooms.map { room ->
if (room.id == roomId) { if (room.id == roomId) {
room.copy( room.copy(
lastMessage = newMessage.content(), lastMessage = newMessage.content(),
@@ -232,13 +245,14 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
room room
} }
}.sortedDescending().toSet() }.sortedDescending().toSet()
currentState.copy(rooms = updatedRooms)
} }
} }
fun resetInternalState() { fun resetInternalState() {
_chatRooms.value = emptySet()
_state.update { _state.update {
it.copy( it.copy(
rooms = emptySet(),
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.NonCancellable
import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@@ -21,7 +20,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayMetadata
@@ -29,6 +27,7 @@ import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
import su.reya.coop.Profile import su.reya.coop.Profile
import su.reya.coop.nostr.Nostr import su.reya.coop.nostr.Nostr
import su.reya.coop.repository.MediaRepository
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -39,6 +38,8 @@ data class NostrAppState(
) )
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
private val mediaRepository = MediaRepository()
private val alwaysRunTasks = flow { private val alwaysRunTasks = flow {
coroutineScope { coroutineScope {
val observerJob = launch { runObserver() } val observerJob = launch { runObserver() }
@@ -92,17 +93,6 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
getCacheMetadata() getCacheMetadata()
} }
override fun onCleared() {
super.onCleared()
// Disconnect to all bootstrap relays
viewModelScope.launch {
withContext(NonCancellable) {
nostr.disconnect()
}
}
}
private fun reconnect() { private fun reconnect() {
viewModelScope.launch { viewModelScope.launch {
nostr.waitUntilInitialized() nostr.waitUntilInitialized()
@@ -248,7 +238,13 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
_appState.update { it.copy(isBusy = true) } _appState.update { it.copy(isBusy = true) }
try { try {
val avatarUrl = val avatarUrl =
picture?.let { blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg") } picture?.let {
mediaRepository.blossomUpload(
nostr.signer.get(),
it,
contentType ?: "image/jpeg"
)
}
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl) val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")