5 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
15 changed files with 270 additions and 250 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,10 +118,22 @@ fun App(
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
ErrorRepository.errors.collect { message -> launch {
authViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message) snackbarHostState.showSnackbar(message)
} }
} }
launch {
chatViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
launch {
nostrViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
}
LaunchedEffect(activity) { LaunchedEffect(activity) {
activity?.let { activity?.let {

View File

@@ -13,6 +13,7 @@ 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.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,12 +27,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 storage = AppStore(this@MainActivity)
private val nostrViewModel = NostrViewModel(NostrManager.instance) private val mediaRepository = MediaRepository()
private val chatViewModel = ChatViewModel(NostrManager.instance) 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 authViewModel = private val authViewModel =
AuthViewModel(NostrManager.instance, storage, 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 {
loading = true
try {
// Import the identity
authViewModel.importIdentity(secret, password) 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()) {
try {
// Connect to the external signer // Connect to the external signer
// TODO: show all available signers? // TODO: show all available signers?
authViewModel.connectExternalSigner() authViewModel.connectExternalSigner()
// Navigate to the home screen
navigator.navigate(Screen.Home)
} catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) }
}
} else { } else {
scope.launch {
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

@@ -120,8 +120,7 @@ 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() }
} }
@@ -129,11 +128,8 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
// Parse the file content type // Parse the file content type
val type = context.contentResolver.getType(uri) val type = context.contentResolver.getType(uri)
// Send message // Send message (handles errors internally via ViewModel)
chatViewModel.sendFileMessage(id, file, type) chatViewModel.sendFileMessage(id, file, type)
} catch (e: Exception) {
snackbarHostState.showSnackbar("Error: ${e.message}")
}
} }
} }

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

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

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

@@ -29,17 +29,15 @@ 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) {
throw IllegalStateException("Failed to get public key from signer", e)
}
signer = newSigner signer = newSigner
_publicKeyFlow.value = pubkey _publicKeyFlow.value = pubkey
} }
}
override suspend fun getPublicKeyAsync(): PublicKey? { override suspend fun getPublicKeyAsync(): PublicKey? {
return get().getPublicKeyAsync() return get().getPublicKeyAsync()

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

@@ -23,15 +23,16 @@ 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 storage: AppStorage, 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"
@@ -161,18 +162,30 @@ class AuthViewModel(
} }
} }
suspend fun importIdentity(secret: String, password: String? = null) { fun importIdentity(secret: String, password: String? = null) {
viewModelScope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val (signer, decryptedSecret) = createSigner(secret, password) val (signer, decryptedSecret) = createSigner(secret, password)
// Update signer // Update signer
nostr.setSigner(signer) nostr.setSigner(signer)
// Persist the secret in the secret storage // Persist the secret in the secret storage
storage.setSecret(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, 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(
@@ -195,31 +208,52 @@ 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
storage.setSecret(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, 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
) { ) {
viewModelScope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val keys = Keys.generate() val keys = Keys.generate()
val secret = keys.secretKey().toBech32() val secret = keys.secretKey().toBech32()
val avatarUrl = picture?.let { val avatarUrl = picture?.let {
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg") mediaRepository.blossomUpload(keys, 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
)
// Persist the secret in the secret storage // Persist the secret in the secret storage
storage.setSecret(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, isImporting = false) }
} catch (e: Exception) {
showError("Identity creation failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
} }
} }

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
viewModelScope.launch {
try { try {
val uri = mediaRepository.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}") 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()
) )
} else { currentState.copy(rooms = currentState.rooms + (roomId to updatedRoom))
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()
@@ -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,15 +141,17 @@ 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)
} }
} }
} }
}
private fun observeSignerAndCheckRelays() { private fun observeSignerAndCheckRelays() {
viewModelScope.launch { viewModelScope.launch {
@@ -190,19 +175,15 @@ 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
} }
} }
}
fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> { fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> {
val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) } val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) }
@@ -224,12 +205,13 @@ 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
) { ) {
viewModelScope.launch {
_appState.update { it.copy(isBusy = true) } _appState.update { it.copy(isBusy = true) }
try { try {
val avatarUrl = val avatarUrl =
@@ -241,7 +223,8 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
) )
} }
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")
// Update the metadata state after successfully published // Update the metadata state after successfully published
updateMetadata(currentUser, Profile(currentUser, newMetadata)) updateMetadata(currentUser, Profile(currentUser, newMetadata))
@@ -250,6 +233,8 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
_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) }
}
} }
} }