chore: refactor the internal structure #33

Merged
reya merged 9 commits from refactor-core into master 2026-07-03 01:20:37 +00:00
7 changed files with 99 additions and 82 deletions
Showing only changes of commit 785afbca08 - Show all commits

View File

@@ -34,6 +34,7 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import su.reya.coop.repository.ErrorRepository
import su.reya.coop.screens.ChatScreen
import su.reya.coop.screens.ContactListScreen
import su.reya.coop.screens.HomeScreen
@@ -117,7 +118,7 @@ fun App(
}
LaunchedEffect(Unit) {
ErrorManager.errors.collect { message ->
ErrorRepository.errors.collect { 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.flow.receiveAsFlow
object ErrorManager {
object ErrorRepository {
private val _errors = Channel<String>(Channel.BUFFERED)
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.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
@@ -29,6 +30,8 @@ class AuthViewModel(
private val secretStore: SecretStorage,
private val externalSignerHandler: ExternalSignerHandler? = null,
) : BaseViewModel() {
private val mediaRepository = MediaRepository()
companion object {
private const val KEY_USER_SIGNER = "user_signer"
private const val KEY_APP_KEYS = "app_keys"
@@ -236,8 +239,9 @@ class AuthViewModel(
val secret = keys.secretKey().toBech32()
try {
val avatarUrl =
picture?.let { blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg") }
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg")
}
// Create identity
nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)

View File

@@ -1,47 +1,10 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
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.ErrorManager
import su.reya.coop.blossom.BlossomClient
import su.reya.coop.repository.ErrorRepository
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) {
ErrorManager.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
}
ErrorRepository.showError(message)
}
}

View File

@@ -5,7 +5,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
@@ -20,19 +20,27 @@ import rust.nostr.sdk.Tag
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.Room
import su.reya.coop.nostr.Nostr
import su.reya.coop.repository.MediaRepository
import su.reya.coop.roomId
data class ChatState(
val rooms: Set<Room> = emptySet(),
val isSyncing: Boolean = false,
val isPartialProcessedGiftWrap: Boolean = false,
)
class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
private val _state = MutableStateFlow(ChatState())
val state = _state.asStateFlow()
private val mediaRepository = MediaRepository()
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
val chatRooms = _chatRooms.asStateFlow()
private val _state = MutableStateFlow(ChatState())
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)
val newEvents = _newEvents.asSharedFlow()
@@ -40,10 +48,13 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
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)
val isPartialProcessedGiftWrap = _state.map { it.isPartialProcessedGiftWrap }
val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
init {
@@ -52,14 +63,14 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
// Observe message sync progress
launch {
nostr.messages.messageSyncState.collect { state ->
nostr.messages.messageSyncState.collect { syncState ->
// 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) }
}
// 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()
}
}
@@ -69,12 +80,16 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
launch {
nostr.newEvents.collect { event ->
val roomId = event.roomId()
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
val existingRoom = _state.value.rooms.firstOrNull { it.id == roomId }
if (existingRoom == null) {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
val newRoom = Room.new(event, currentUser)
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
_state.update {
it.copy(
rooms = (it.rooms + newRoom).sortedDescending().toSet()
)
}
} else {
updateRoomList(roomId, event)
}
@@ -105,7 +120,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
// Check if the room already exists
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 (existingRoom != null) {
@@ -116,9 +131,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
val room = Room.new(rumor, currentUser)
// Update the chat rooms state
_chatRooms.update { currentRooms ->
(currentRooms + room).sortedDescending().toSet()
}
_state.update { it.copy(rooms = (it.rooms + room).sortedDescending().toSet()) }
return room.id
} catch (e: Exception) {
@@ -127,20 +140,20 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
}
fun getChatRoom(id: Long): Room? {
return chatRooms.value.firstOrNull { it.id == id }
return _state.value.rooms.firstOrNull { it.id == id }
}
suspend fun refreshChatRooms() {
try {
val rooms = nostr.messages.getChatRooms() ?: emptySet()
_chatRooms.update { currentRooms ->
val merged = currentRooms.associateBy { it.id }.toMutableMap()
_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
merged.values.sortedDescending().toSet()
currentState.copy(rooms = merged.values.sortedDescending().toSet())
}
} catch (e: Exception) {
showError("Error: ${e.message}")
@@ -202,7 +215,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
if (file == null) return
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)
} catch (e: Exception) {
throw IllegalArgumentException("Error: ${e.message}")
@@ -221,8 +234,8 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
}
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
_chatRooms.update { currentRooms ->
currentRooms.map { room ->
_state.update { currentState ->
val updatedRooms = currentState.rooms.map { room ->
if (room.id == roomId) {
room.copy(
lastMessage = newMessage.content(),
@@ -232,13 +245,14 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
room
}
}.sortedDescending().toSet()
currentState.copy(rooms = updatedRooms)
}
}
fun resetInternalState() {
_chatRooms.value = emptySet()
_state.update {
it.copy(
rooms = emptySet(),
isPartialProcessedGiftWrap = false,
)
}

View File

@@ -2,7 +2,6 @@ package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
@@ -21,7 +20,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata
@@ -29,6 +27,7 @@ import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Timestamp
import su.reya.coop.Profile
import su.reya.coop.nostr.Nostr
import su.reya.coop.repository.MediaRepository
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@@ -39,6 +38,8 @@ data class NostrAppState(
)
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
private val mediaRepository = MediaRepository()
private val alwaysRunTasks = flow {
coroutineScope {
val observerJob = launch { runObserver() }
@@ -92,17 +93,6 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
getCacheMetadata()
}
override fun onCleared() {
super.onCleared()
// Disconnect to all bootstrap relays
viewModelScope.launch {
withContext(NonCancellable) {
nostr.disconnect()
}
}
}
private fun reconnect() {
viewModelScope.launch {
nostr.waitUntilInitialized()
@@ -248,7 +238,13 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
_appState.update { it.copy(isBusy = true) }
try {
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 currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")