From 830375f562de4862c1bfcaba5fa4d60ffb654a97 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 2 Jul 2026 15:09:01 +0700 Subject: [PATCH] add chat viewmodel --- .../androidMain/kotlin/su/reya/coop/App.kt | 6 + .../kotlin/su/reya/coop/MainActivity.kt | 5 + .../kotlin/su/reya/coop/screens/ChatScreen.kt | 14 +- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 24 +- .../su/reya/coop/screens/NewChatScreen.kt | 8 +- .../su/reya/coop/screens/ProfileScreen.kt | 14 +- .../su/reya/coop/screens/RequestListScreen.kt | 6 +- .../kotlin/su/reya/coop/ChatViewModel.kt | 245 ++++++++++++++++++ .../kotlin/su/reya/coop/NostrViewModel.kt | 242 +---------------- 9 files changed, 305 insertions(+), 259 deletions(-) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/ChatViewModel.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index ee7616c..067a5ba 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -52,6 +52,10 @@ val LocalNostrViewModel = staticCompositionLocalOf { error("No NostrViewModel provided") } +val LocalChatViewModel = staticCompositionLocalOf { + error("No ChatViewModel provided") +} + val LocalAuthViewModel = staticCompositionLocalOf { error("No AuthViewModel provided") } @@ -72,6 +76,7 @@ val LocalScanResult = staticCompositionLocalOf { @Composable fun App( nostrViewModel: NostrViewModel, + chatViewModel: ChatViewModel, authViewModel: AuthViewModel, ) { val context = LocalContext.current @@ -153,6 +158,7 @@ fun App( ) { CompositionLocalProvider( LocalNostrViewModel provides nostrViewModel, + LocalChatViewModel provides chatViewModel, LocalAuthViewModel provides authViewModel, LocalSnackbarHostState provides snackbarHostState, LocalNavigator provides navigator, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index e816366..0bcb529 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -28,12 +28,15 @@ class MainActivity : ComponentActivity() { 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) override fun create(modelClass: Class): T { return when { modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel + modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel else -> throw IllegalArgumentException("Unknown ViewModel class") } as T @@ -42,6 +45,7 @@ class MainActivity : ComponentActivity() { } private val nostrViewModel: NostrViewModel by viewModels { factory } + private val chatViewModel: ChatViewModel by viewModels { factory } private val authViewModel: AuthViewModel by viewModels { factory } override fun onCreate(savedInstanceState: Bundle?) { @@ -87,6 +91,7 @@ class MainActivity : ComponentActivity() { setContent { App( nostrViewModel = nostrViewModel, + chatViewModel = chatViewModel, authViewModel = authViewModel, ) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index 1a02a1c..c53a642 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -76,6 +76,7 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent +import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState @@ -96,6 +97,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current val nostrViewModel = LocalNostrViewModel.current + val chatViewModel = LocalChatViewModel.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() @@ -103,7 +105,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() // Get chat room by ID - val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle() + val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } } // Show empty screen @@ -144,7 +146,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { val type = context.contentResolver.getType(uri) // Send message - nostrViewModel.sendFileMessage(id, file, type) + chatViewModel.sendFileMessage(id, file, type) } catch (e: Exception) { snackbarHostState.showSnackbar("Error: ${e.message}") } @@ -157,7 +159,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { LaunchedEffect(id) { // Get messages - val initialMessages = nostrViewModel.getChatRoomMessages(id) + val initialMessages = chatViewModel.getChatRoomMessages(id) messages.clear() messages.addAll(initialMessages) @@ -165,10 +167,10 @@ fun ChatScreen(id: Long, screening: Boolean = false) { loading = false // Get msg relays for each member - nostrViewModel.chatRoomConnect(id) + chatViewModel.chatRoomConnect(id) // Handle new messages - nostrViewModel.newEvents.collect { event -> + chatViewModel.newEvents.collect { event -> if (event.roomId() == id) { if (event.id() !in messages.map { it.id() }) { messages.add(0, event) @@ -350,7 +352,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { value = text, onValueChange = { text = it }, onSend = { - nostrViewModel.sendMessage(id, text) + chatViewModel.sendMessage(id, text) text = "" }, onUpload = { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 498e766..b9d1ee2 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -91,6 +91,7 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey import su.reya.coop.LocalAuthViewModel +import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalScanResult @@ -112,6 +113,7 @@ fun HomeScreen() { val snackbarHostState = LocalSnackbarHostState.current val clipboardManager = LocalClipboard.current val nostrViewModel = LocalNostrViewModel.current + val chatViewModel = LocalChatViewModel.current val authViewModel = LocalAuthViewModel.current val scope = rememberCoroutineScope() @@ -120,11 +122,11 @@ fun HomeScreen() { val pullToRefreshState = rememberPullToRefreshState() val userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() - val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle() + val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle() - val isSyncing by nostrViewModel.isSyncing.collectAsStateWithLifecycle() - val isPartialProcessedGiftWrap by nostrViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() + val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle() + val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() val authState by authViewModel.state.collectAsStateWithLifecycle() val isBannerDismissed = authState.isNotificationBannerDismissed @@ -155,7 +157,7 @@ fun HomeScreen() { } LaunchedEffect(Unit) { - nostrViewModel.getChatRooms() + chatViewModel.refreshChatRooms() } LaunchedEffect(qrScanResult.content) { @@ -163,7 +165,7 @@ fun HomeScreen() { runCatching { PublicKey.parse(result) } .onSuccess { pubkey -> try { - val roomId = nostrViewModel.createChatRoom(listOf(pubkey)) + val roomId = chatViewModel.createChatRoom(listOf(pubkey)) navigator.navigate(Screen.Chat(roomId)) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } @@ -320,7 +322,7 @@ fun HomeScreen() { onRefresh = { scope.launch { isRefreshing = true - nostrViewModel.refreshChatRooms() + chatViewModel.refreshChatRooms() isRefreshing = false } }, @@ -746,6 +748,7 @@ fun BottomMenuList( ) { val navigator = LocalNavigator.current val nostrViewModel = LocalNostrViewModel.current + val chatViewModel = LocalChatViewModel.current val authViewModel = LocalAuthViewModel.current val defaultMenuList = listOf( @@ -776,7 +779,14 @@ fun BottomMenuList( } Spacer(modifier = Modifier.size(16.dp)) FilledTonalButton( - onClick = { onDismiss { authViewModel.logout(onLogout = nostrViewModel::resetInternalState) } }, + onClick = { + onDismiss { + authViewModel.logout(onLogout = { + nostrViewModel.resetInternalState() + chatViewModel.resetInternalState() + }) + } + }, colors = ButtonDefaults.filledTonalButtonColors( containerColor = MaterialTheme.colorScheme.error, contentColor = MaterialTheme.colorScheme.onError diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt index 095a876..2c2fb10 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt @@ -55,6 +55,7 @@ import coop.composeapp.generated.resources.ic_scanner import kotlinx.coroutines.delay import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey +import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalScanResult @@ -71,6 +72,7 @@ fun NewChatScreen() { val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current val nostrViewModel = LocalNostrViewModel.current + val chatViewModel = LocalChatViewModel.current val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle() var query by remember { mutableStateOf("") } @@ -169,7 +171,7 @@ fun NewChatScreen() { ) { ExtendedFloatingActionButton( onClick = { - val roomId = nostrViewModel.createChatRoom(selectedReceivers.toList()) + val roomId = chatViewModel.createChatRoom(selectedReceivers.toList()) navigator.navigate(Screen.Chat(roomId)) }, expanded = false, @@ -260,7 +262,7 @@ fun NewChatScreen() { items = searchResults, selectedReceivers = selectedReceivers, onContactClick = { pubkey -> - val roomId = nostrViewModel.createChatRoom(listOf(pubkey)) + val roomId = chatViewModel.createChatRoom(listOf(pubkey)) navigator.navigate(Screen.Chat(roomId)) }, ) @@ -271,7 +273,7 @@ fun NewChatScreen() { items = contactList.toList(), selectedReceivers = selectedReceivers, onContactClick = { pubkey -> - val roomId = nostrViewModel.createChatRoom(listOf(pubkey)) + val roomId = chatViewModel.createChatRoom(listOf(pubkey)) navigator.navigate(Screen.Chat(roomId)) } ) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt index 3fefa14..35cd311 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt @@ -44,6 +44,7 @@ import coop.composeapp.generated.resources.ic_share import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey +import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState @@ -55,19 +56,20 @@ import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ProfileScreen(pubkey: String) { + val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return + val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current val nostrViewModel = LocalNostrViewModel.current + val chatViewModel = LocalChatViewModel.current val scope = rememberCoroutineScope() - val pubkeyObj = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return - - val profileFlow = remember(pubkeyObj) { nostrViewModel.getMetadata(pubkeyObj) } + val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } val profile by profileFlow.collectAsStateWithLifecycle() val metadata = profile?.metadata?.asRecord() - val nip05 = metadata?.nip05 ?: pubkeyObj.short() + val nip05 = metadata?.nip05 ?: pubkey.short() val picture = metadata?.picture val details = remember(profile) { @@ -159,7 +161,7 @@ fun ProfileScreen(pubkey: String) { scope.launch { try { val roomId = - nostrViewModel.createChatRoom(listOf(pubkeyObj)) + chatViewModel.createChatRoom(listOf(pubkey)) navigator.navigate(Screen.Chat(roomId)) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } @@ -189,7 +191,7 @@ fun ProfileScreen(pubkey: String) { onClick = { val sendIntent = Intent().apply { action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, pubkeyObj.toBech32()) + putExtra(Intent.EXTRA_TEXT, pubkey.toBech32()) type = "text/plain" } val shareIntent = Intent.createChooser(sendIntent, null) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt index c686c90..64985f1 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt @@ -37,6 +37,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import kotlinx.coroutines.launch +import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState @@ -49,13 +50,14 @@ fun RequestListScreen() { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current val nostrViewModel = LocalNostrViewModel.current + val chatViewModel = LocalChatViewModel.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() val pullToRefreshState = rememberPullToRefreshState() var isRefreshing by remember { mutableStateOf(false) } - val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle() + val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() // Get all request rooms val requests = remember(chatRooms) { @@ -103,7 +105,7 @@ fun RequestListScreen() { onRefresh = { scope.launch { isRefreshing = true - nostrViewModel.refreshChatRooms() + chatViewModel.refreshChatRooms() isRefreshing = false } }, diff --git a/shared/src/commonMain/kotlin/su/reya/coop/ChatViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/ChatViewModel.kt new file mode 100644 index 0000000..ebbc671 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/ChatViewModel.kt @@ -0,0 +1,245 @@ +package su.reya.coop + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import rust.nostr.sdk.EventBuilder +import rust.nostr.sdk.EventId +import rust.nostr.sdk.Kind +import rust.nostr.sdk.KindStandard +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayUrl +import rust.nostr.sdk.Tag +import rust.nostr.sdk.UnsignedEvent +import su.reya.coop.nostr.Nostr + +data class ChatState( + 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 _chatRooms = MutableStateFlow>(emptySet()) + val chatRooms = _chatRooms.asStateFlow() + + private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) + val newEvents = _newEvents.asSharedFlow() + + private val _sentReports = MutableSharedFlow>>() + val sentReport = _sentReports.asSharedFlow() + + val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + val isPartialProcessedGiftWrap = _state.map { it.isPartialProcessedGiftWrap } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + init { + viewModelScope.launch { + nostr.waitUntilInitialized() + + // Observe message sync progress + launch { + nostr.messages.messageSyncState.collect { state -> + // When at least some messages are processed, allow UI to show the list + if (state.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) { + refreshChatRooms() + } + } + } + + // Observe new messages + launch { + nostr.newEvents.collect { event -> + val roomId = event.roomId() + val existingRoom = _chatRooms.value.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() } + } else { + updateRoomList(roomId, event) + } + + _newEvents.emit(event) + } + } + + // Initial load of rooms + refreshChatRooms() + } + } + + fun createChatRoom(to: List): Long { + try { + if (to.isEmpty()) { + throw IllegalArgumentException("At least one recipient is required") + } + + // Get current user + val currentUser = nostr.signer.publicKeyFlow.value + ?: throw IllegalStateException("User not signed in") + + // Construct the rumor event + val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "") + .tags(to.map { Tag.publicKey(it) }) + .finalizeUnsigned(currentUser) + + // Check if the room already exists + val id = rumor.roomId() + val existingRoom = _chatRooms.value.firstOrNull { it.id == id } + + // If the room already exists, return its ID + if (existingRoom != null) { + return existingRoom.id + } + + // Create a room from the rumor event + val room = Room.new(rumor, currentUser) + + // Update the chat rooms state + _chatRooms.update { currentRooms -> + (currentRooms + room).sortedDescending().toSet() + } + + return room.id + } catch (e: Exception) { + throw IllegalArgumentException("Failed to create room: ${e.message}") + } + } + + fun getChatRoom(id: Long): Room? { + return chatRooms.value.firstOrNull { it.id == id } + } + + suspend fun refreshChatRooms() { + try { + val rooms = nostr.messages.getChatRooms() ?: emptySet() + _chatRooms.update { currentRooms -> + val merged = currentRooms.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() + } + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + + suspend fun getChatRoomMessages(roomId: Long): List { + try { + return nostr.messages.getChatRoomMessages(roomId) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + + return emptyList() + } + + fun chatRoomConnect(roomId: Long) { + viewModelScope.launch { + try { + val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") + val members = room.members + + nostr.messages.chatRoomConnect(members.toList()) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun sendMessage(roomId: Long, message: String, replies: List = emptyList()) { + if (message.isEmpty()) { + showError("Message cannot be empty") + } + viewModelScope.launch { + try { + val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") + nostr.messages.sendMessage( + to = room.members, + content = message, + subject = room.subject, + replies = replies, + onRumorCreated = { event -> + updateRoomList(roomId, event) + viewModelScope.launch { _newEvents.emit(event) } + }, + ) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + suspend fun sendFileMessage( + roomId: Long, + file: ByteArray?, + contentType: String? = "image/jpeg", + replies: List = emptyList() + ) { + if (file == null) return + + try { + val uri = blossomUpload(nostr.signer.get(), file, contentType) + if (uri != null) sendMessage(roomId, uri, replies) + } catch (e: Exception) { + throw IllegalArgumentException("Error: ${e.message}") + } + } + + fun isMessageSent(id: EventId): Boolean { + val giftWrapId = nostr.messages.rumorMap[id] + + if (giftWrapId != null) { + val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false + return isSent + } else { + return false + } + } + + private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) { + _chatRooms.update { currentRooms -> + currentRooms.map { room -> + if (room.id == roomId) { + room.copy( + lastMessage = newMessage.content(), + createdAt = newMessage.createdAt() + ) + } else { + room + } + }.sortedDescending().toSet() + } + } + + fun resetInternalState() { + _chatRooms.value = emptySet() + _state.update { + it.copy( + isPartialProcessedGiftWrap = false, + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index fffec80..1d9f6df 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -7,11 +7,9 @@ import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest @@ -25,16 +23,10 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull -import rust.nostr.sdk.EventBuilder -import rust.nostr.sdk.EventId -import rust.nostr.sdk.Kind -import rust.nostr.sdk.KindStandard import rust.nostr.sdk.PublicKey import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayUrl -import rust.nostr.sdk.Tag import rust.nostr.sdk.Timestamp -import rust.nostr.sdk.UnsignedEvent import su.reya.coop.nostr.Nostr import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds @@ -43,12 +35,10 @@ import kotlin.time.Duration.Companion.seconds data class NostrAppState( val isBusy: Boolean = false, val isRelayListEmpty: Boolean = false, - val isSyncing: Boolean = false, - val isPartialProcessedGiftWrap: Boolean = false, ) class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { - private val backgroundWorkTrigger = flow { + private val alwaysRunTasks = flow { coroutineScope { val observerJob = launch { runObserver() } val batchingJob = launch { runMetadataBatching() } @@ -63,30 +53,16 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { } private val _appState = MutableStateFlow(NostrAppState()) - val appState: StateFlow = combine( - _appState, - nostr.messages.messageSyncState.map { it.isSyncing }, - backgroundWorkTrigger - ) { state, isSyncing, _ -> - state.copy(isSyncing = isSyncing) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = NostrAppState() - ) - - private val _chatRooms = MutableStateFlow>(emptySet()) - val chatRooms = _chatRooms.asStateFlow() + val appState: StateFlow = + combine(_appState, alwaysRunTasks) { state, _ -> state }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = NostrAppState() + ) private val _contactList = MutableStateFlow>(emptySet()) val contactList = _contactList.asStateFlow() - private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) - val newEvents = _newEvents.asSharedFlow() - - private val _sentReports = MutableSharedFlow>>() - val sentReport = _sentReports.asSharedFlow() - private val profilesMutex = Mutex() private val profiles = mutableMapOf>() private val metadataRequestChannel = Channel(Channel.UNLIMITED) @@ -94,12 +70,8 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { val isBusy = appState.map { it.isBusy } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - val isPartialProcessedGiftWrap = appState.map { it.isPartialProcessedGiftWrap } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) val isRelayListEmpty = appState.map { it.isRelayListEmpty } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - val isSyncing = appState.map { it.isSyncing } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) @OptIn(ExperimentalCoroutinesApi::class) val currentUserProfile = nostr.signer.publicKeyFlow @@ -138,39 +110,6 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { } private suspend fun runObserver() = coroutineScope { - // Observe message sync progress - launch { - nostr.messages.messageSyncState.collect { state -> - // When at least some messages are processed, allow UI to show the list - if (state.processedCount > 0) { - _appState.update { it.copy(isPartialProcessedGiftWrap = true) } - } - - // Refresh UI every 10 messages OR when sync is fully done - if (state.processedCount % 10 == 0 || !state.isSyncing) { - refreshChatRooms() - } - } - } - - // Observe new messages - launch { - nostr.newEvents.collect { event -> - val roomId = event.roomId() - val existingRoom = _chatRooms.value.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() } - } else { - updateRoomList(roomId, event) - } - - _newEvents.emit(event) - } - } - // Observe contact list updates launch { nostr.profiles.contactListUpdates.collect { contacts -> @@ -245,13 +184,6 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { val currentUser = nostr.signer.getPublicKeyAsync() if (currentUser != null) { - // Get chat rooms - val rooms = nostr.messages.getChatRooms() ?: emptySet() - if (rooms.isNotEmpty()) { - mergeChatRooms(rooms) - _appState.update { it.copy(isPartialProcessedGiftWrap = true) } - } - // Get all metadata for the current user nostr.profiles.getUserMetadata() @@ -294,11 +226,9 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { } fun resetInternalState() { - _chatRooms.value = emptySet() _contactList.value = emptySet() _appState.update { it.copy( - isPartialProcessedGiftWrap = false, isRelayListEmpty = false, ) } @@ -475,164 +405,6 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { } } - fun createChatRoom(to: List): Long { - try { - if (to.isEmpty()) { - throw IllegalArgumentException("At least one recipient is required") - } - - // Get current user - val currentUser = nostr.signer.publicKeyFlow.value - ?: throw IllegalStateException("User not signed in") - - // Construct the rumor event - val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "") - .tags(to.map { Tag.publicKey(it) }) - .finalizeUnsigned(currentUser) - - // Check if the room already exists - val id = rumor.roomId() - val existingRoom = _chatRooms.value.firstOrNull { it.id == id } - - // If the room already exists, return its ID - if (existingRoom != null) { - return existingRoom.id - } - - // Create a room from the rumor event - val room = Room.new(rumor, currentUser) - - // Update the chat rooms state - _chatRooms.update { currentRooms -> - (currentRooms + room).sortedDescending().toSet() - } - - return room.id - } catch (e: Exception) { - throw IllegalArgumentException("Failed to create room: ${e.message}") - } - } - - fun getChatRoom(id: Long): Room? { - return chatRooms.value.firstOrNull { it.id == id } - } - - private fun mergeChatRooms(rooms: Set) { - _chatRooms.update { currentRooms -> - val merged = currentRooms.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() - } - } - - fun getChatRooms() { - viewModelScope.launch { - val rooms = nostr.messages.getChatRooms() ?: emptySet() - mergeChatRooms(rooms) - } - } - - suspend fun refreshChatRooms() { - try { - val rooms = nostr.messages.getChatRooms() ?: emptySet() - mergeChatRooms(rooms) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - - suspend fun getChatRoomMessages(roomId: Long): List { - try { - return nostr.messages.getChatRoomMessages(roomId) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - - return emptyList() - } - - fun chatRoomConnect(roomId: Long) { - viewModelScope.launch { - try { - val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") - val members = room.members - - nostr.messages.chatRoomConnect(members.toList()) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - } - - fun sendMessage(roomId: Long, message: String, replies: List = emptyList()) { - if (message.isEmpty()) { - showError("Message cannot be empty") - } - viewModelScope.launch { - try { - val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") - nostr.messages.sendMessage( - to = room.members, - content = message, - subject = room.subject, - replies = replies, - onRumorCreated = { event -> - updateRoomList(roomId, event) - viewModelScope.launch { _newEvents.emit(event) } - }, - ) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - } - - suspend fun sendFileMessage( - roomId: Long, - file: ByteArray?, - contentType: String? = "image/jpeg", - replies: List = emptyList() - ) { - if (file == null) return - - try { - val uri = blossomUpload(nostr.signer.get(), file, contentType) - if (uri != null) sendMessage(roomId, uri, replies) - } catch (e: Exception) { - throw IllegalArgumentException("Error: ${e.message}") - } - } - - fun isMessageSent(id: EventId): Boolean { - val giftWrapId = nostr.messages.rumorMap[id] - - if (giftWrapId != null) { - val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false - return isSent - } else { - return false - } - } - - private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) { - _chatRooms.update { currentRooms -> - currentRooms.map { room -> - if (room.id == roomId) { - room.copy( - lastMessage = newMessage.content(), - createdAt = newMessage.createdAt() - ) - } else { - room - } - }.sortedDescending().toSet() - } - } - suspend fun searchByAddress(query: String): PublicKey? { try { return nostr.profiles.searchByAddress(query)