add chat viewmodel

This commit is contained in:
2026-07-02 15:09:01 +07:00
parent e5f3078c9c
commit 830375f562
9 changed files with 305 additions and 259 deletions

View File

@@ -52,6 +52,10 @@ val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
error("No NostrViewModel provided") error("No NostrViewModel provided")
} }
val LocalChatViewModel = staticCompositionLocalOf<ChatViewModel> {
error("No ChatViewModel provided")
}
val LocalAuthViewModel = staticCompositionLocalOf<AuthViewModel> { val LocalAuthViewModel = staticCompositionLocalOf<AuthViewModel> {
error("No AuthViewModel provided") error("No AuthViewModel provided")
} }
@@ -72,6 +76,7 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
@Composable @Composable
fun App( fun App(
nostrViewModel: NostrViewModel, nostrViewModel: NostrViewModel,
chatViewModel: ChatViewModel,
authViewModel: AuthViewModel, authViewModel: AuthViewModel,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -153,6 +158,7 @@ fun App(
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalNostrViewModel provides nostrViewModel, LocalNostrViewModel provides nostrViewModel,
LocalChatViewModel provides chatViewModel,
LocalAuthViewModel provides authViewModel, LocalAuthViewModel provides authViewModel,
LocalSnackbarHostState provides snackbarHostState, LocalSnackbarHostState provides snackbarHostState,
LocalNavigator provides navigator, LocalNavigator provides navigator,

View File

@@ -28,12 +28,15 @@ class MainActivity : ComponentActivity() {
private val secretStore = SecretStore(this@MainActivity) private val secretStore = SecretStore(this@MainActivity)
private val nostrViewModel = private val nostrViewModel =
NostrViewModel(NostrManager.instance) NostrViewModel(NostrManager.instance)
private val chatViewModel =
ChatViewModel(NostrManager.instance)
private val authViewModel = private val authViewModel =
AuthViewModel(NostrManager.instance, secretStore, androidSigner) AuthViewModel(NostrManager.instance, secretStore, androidSigner)
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when { return when {
modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel
modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel
else -> throw IllegalArgumentException("Unknown ViewModel class") else -> throw IllegalArgumentException("Unknown ViewModel class")
} as T } as T
@@ -42,6 +45,7 @@ class MainActivity : ComponentActivity() {
} }
private val nostrViewModel: NostrViewModel by viewModels { factory } private val nostrViewModel: NostrViewModel by viewModels { factory }
private val chatViewModel: ChatViewModel by viewModels { factory }
private val authViewModel: AuthViewModel by viewModels { factory } private val authViewModel: AuthViewModel by viewModels { factory }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -87,6 +91,7 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
App( App(
nostrViewModel = nostrViewModel, nostrViewModel = nostrViewModel,
chatViewModel = chatViewModel,
authViewModel = authViewModel, authViewModel = authViewModel,
) )
} }

View File

@@ -76,6 +76,7 @@ import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
@@ -96,6 +97,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -103,7 +105,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
// Get chat room by ID // 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 } } } val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
// Show empty screen // Show empty screen
@@ -144,7 +146,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
val type = context.contentResolver.getType(uri) val type = context.contentResolver.getType(uri)
// Send message // Send message
nostrViewModel.sendFileMessage(id, file, type) chatViewModel.sendFileMessage(id, file, type)
} catch (e: Exception) { } catch (e: Exception) {
snackbarHostState.showSnackbar("Error: ${e.message}") snackbarHostState.showSnackbar("Error: ${e.message}")
} }
@@ -157,7 +159,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
LaunchedEffect(id) { LaunchedEffect(id) {
// Get messages // Get messages
val initialMessages = nostrViewModel.getChatRoomMessages(id) val initialMessages = chatViewModel.getChatRoomMessages(id)
messages.clear() messages.clear()
messages.addAll(initialMessages) messages.addAll(initialMessages)
@@ -165,10 +167,10 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
loading = false loading = false
// Get msg relays for each member // Get msg relays for each member
nostrViewModel.chatRoomConnect(id) chatViewModel.chatRoomConnect(id)
// Handle new messages // Handle new messages
nostrViewModel.newEvents.collect { event -> chatViewModel.newEvents.collect { event ->
if (event.roomId() == id) { if (event.roomId() == id) {
if (event.id() !in messages.map { it.id() }) { if (event.id() !in messages.map { it.id() }) {
messages.add(0, event) messages.add(0, event)
@@ -350,7 +352,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
value = text, value = text,
onValueChange = { text = it }, onValueChange = { text = it },
onSend = { onSend = {
nostrViewModel.sendMessage(id, text) chatViewModel.sendMessage(id, text)
text = "" text = ""
}, },
onUpload = { onUpload = {

View File

@@ -91,6 +91,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalAuthViewModel import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult import su.reya.coop.LocalScanResult
@@ -112,6 +113,7 @@ fun HomeScreen() {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val clipboardManager = LocalClipboard.current val clipboardManager = LocalClipboard.current
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val authViewModel = LocalAuthViewModel.current val authViewModel = LocalAuthViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -120,11 +122,11 @@ fun HomeScreen() {
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
val userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() 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 isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
val isSyncing by nostrViewModel.isSyncing.collectAsStateWithLifecycle() val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by nostrViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
val authState by authViewModel.state.collectAsStateWithLifecycle() val authState by authViewModel.state.collectAsStateWithLifecycle()
val isBannerDismissed = authState.isNotificationBannerDismissed val isBannerDismissed = authState.isNotificationBannerDismissed
@@ -155,7 +157,7 @@ fun HomeScreen() {
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
nostrViewModel.getChatRooms() chatViewModel.refreshChatRooms()
} }
LaunchedEffect(qrScanResult.content) { LaunchedEffect(qrScanResult.content) {
@@ -163,7 +165,7 @@ fun HomeScreen() {
runCatching { PublicKey.parse(result) } runCatching { PublicKey.parse(result) }
.onSuccess { pubkey -> .onSuccess { pubkey ->
try { try {
val roomId = nostrViewModel.createChatRoom(listOf(pubkey)) val roomId = chatViewModel.createChatRoom(listOf(pubkey))
navigator.navigate(Screen.Chat(roomId)) navigator.navigate(Screen.Chat(roomId))
} catch (e: Exception) { } catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) } e.message?.let { snackbarHostState.showSnackbar(it) }
@@ -320,7 +322,7 @@ fun HomeScreen() {
onRefresh = { onRefresh = {
scope.launch { scope.launch {
isRefreshing = true isRefreshing = true
nostrViewModel.refreshChatRooms() chatViewModel.refreshChatRooms()
isRefreshing = false isRefreshing = false
} }
}, },
@@ -746,6 +748,7 @@ fun BottomMenuList(
) { ) {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val authViewModel = LocalAuthViewModel.current val authViewModel = LocalAuthViewModel.current
val defaultMenuList = listOf( val defaultMenuList = listOf(
@@ -776,7 +779,14 @@ fun BottomMenuList(
} }
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
FilledTonalButton( FilledTonalButton(
onClick = { onDismiss { authViewModel.logout(onLogout = nostrViewModel::resetInternalState) } }, onClick = {
onDismiss {
authViewModel.logout(onLogout = {
nostrViewModel.resetInternalState()
chatViewModel.resetInternalState()
})
}
},
colors = ButtonDefaults.filledTonalButtonColors( colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.error, containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError contentColor = MaterialTheme.colorScheme.onError

View File

@@ -55,6 +55,7 @@ import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult import su.reya.coop.LocalScanResult
@@ -71,6 +72,7 @@ fun NewChatScreen() {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current val qrScanResult = LocalScanResult.current
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle() val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
@@ -169,7 +171,7 @@ fun NewChatScreen() {
) { ) {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
onClick = { onClick = {
val roomId = nostrViewModel.createChatRoom(selectedReceivers.toList()) val roomId = chatViewModel.createChatRoom(selectedReceivers.toList())
navigator.navigate(Screen.Chat(roomId)) navigator.navigate(Screen.Chat(roomId))
}, },
expanded = false, expanded = false,
@@ -260,7 +262,7 @@ fun NewChatScreen() {
items = searchResults, items = searchResults,
selectedReceivers = selectedReceivers, selectedReceivers = selectedReceivers,
onContactClick = { pubkey -> onContactClick = { pubkey ->
val roomId = nostrViewModel.createChatRoom(listOf(pubkey)) val roomId = chatViewModel.createChatRoom(listOf(pubkey))
navigator.navigate(Screen.Chat(roomId)) navigator.navigate(Screen.Chat(roomId))
}, },
) )
@@ -271,7 +273,7 @@ fun NewChatScreen() {
items = contactList.toList(), items = contactList.toList(),
selectedReceivers = selectedReceivers, selectedReceivers = selectedReceivers,
onContactClick = { pubkey -> onContactClick = { pubkey ->
val roomId = nostrViewModel.createChatRoom(listOf(pubkey)) val roomId = chatViewModel.createChatRoom(listOf(pubkey))
navigator.navigate(Screen.Chat(roomId)) navigator.navigate(Screen.Chat(roomId))
} }
) )

View File

@@ -44,6 +44,7 @@ import coop.composeapp.generated.resources.ic_share
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
@@ -55,19 +56,20 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ProfileScreen(pubkey: String) { fun ProfileScreen(pubkey: String) {
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val pubkeyObj = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val profileFlow = remember(pubkeyObj) { nostrViewModel.getMetadata(pubkeyObj) }
val profile by profileFlow.collectAsStateWithLifecycle() val profile by profileFlow.collectAsStateWithLifecycle()
val metadata = profile?.metadata?.asRecord() val metadata = profile?.metadata?.asRecord()
val nip05 = metadata?.nip05 ?: pubkeyObj.short() val nip05 = metadata?.nip05 ?: pubkey.short()
val picture = metadata?.picture val picture = metadata?.picture
val details = remember(profile) { val details = remember(profile) {
@@ -159,7 +161,7 @@ fun ProfileScreen(pubkey: String) {
scope.launch { scope.launch {
try { try {
val roomId = val roomId =
nostrViewModel.createChatRoom(listOf(pubkeyObj)) chatViewModel.createChatRoom(listOf(pubkey))
navigator.navigate(Screen.Chat(roomId)) navigator.navigate(Screen.Chat(roomId))
} catch (e: Exception) { } catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) } e.message?.let { snackbarHostState.showSnackbar(it) }
@@ -189,7 +191,7 @@ fun ProfileScreen(pubkey: String) {
onClick = { onClick = {
val sendIntent = Intent().apply { val sendIntent = Intent().apply {
action = Intent.ACTION_SEND action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, pubkeyObj.toBech32()) putExtra(Intent.EXTRA_TEXT, pubkey.toBech32())
type = "text/plain" type = "text/plain"
} }
val shareIntent = Intent.createChooser(sendIntent, null) val shareIntent = Intent.createChooser(sendIntent, null)

View File

@@ -37,6 +37,7 @@ 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 kotlinx.coroutines.launch import kotlinx.coroutines.launch
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
@@ -49,13 +50,14 @@ fun RequestListScreen() {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
var isRefreshing by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) }
val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle() val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
// Get all request rooms // Get all request rooms
val requests = remember(chatRooms) { val requests = remember(chatRooms) {
@@ -103,7 +105,7 @@ fun RequestListScreen() {
onRefresh = { onRefresh = {
scope.launch { scope.launch {
isRefreshing = true isRefreshing = true
nostrViewModel.refreshChatRooms() chatViewModel.refreshChatRooms()
isRefreshing = false isRefreshing = false
} }
}, },

View File

@@ -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<Set<Room>>(emptySet())
val chatRooms = _chatRooms.asStateFlow()
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow()
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
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<PublicKey>): 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<UnsignedEvent> {
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<EventId> = 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<EventId> = 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,
)
}
}
}

View File

@@ -7,11 +7,9 @@ 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
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.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
@@ -25,16 +23,10 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull 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.PublicKey
import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Tag
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.nostr.Nostr import su.reya.coop.nostr.Nostr
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -43,12 +35,10 @@ import kotlin.time.Duration.Companion.seconds
data class NostrAppState( data class NostrAppState(
val isBusy: Boolean = false, val isBusy: Boolean = false,
val isRelayListEmpty: Boolean = false, val isRelayListEmpty: Boolean = false,
val isSyncing: Boolean = false,
val isPartialProcessedGiftWrap: Boolean = false,
) )
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
private val backgroundWorkTrigger = flow { private val alwaysRunTasks = flow {
coroutineScope { coroutineScope {
val observerJob = launch { runObserver() } val observerJob = launch { runObserver() }
val batchingJob = launch { runMetadataBatching() } val batchingJob = launch { runMetadataBatching() }
@@ -63,30 +53,16 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
} }
private val _appState = MutableStateFlow(NostrAppState()) private val _appState = MutableStateFlow(NostrAppState())
val appState: StateFlow<NostrAppState> = combine( val appState: StateFlow<NostrAppState> =
_appState, combine(_appState, alwaysRunTasks) { state, _ -> state }.stateIn(
nostr.messages.messageSyncState.map { it.isSyncing }, scope = viewModelScope,
backgroundWorkTrigger started = SharingStarted.WhileSubscribed(5000),
) { state, isSyncing, _ -> initialValue = NostrAppState()
state.copy(isSyncing = isSyncing) )
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = NostrAppState()
)
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
val chatRooms = _chatRooms.asStateFlow()
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet()) private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList = _contactList.asStateFlow() val contactList = _contactList.asStateFlow()
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow()
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
val sentReport = _sentReports.asSharedFlow()
private val profilesMutex = Mutex() private val profilesMutex = Mutex()
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>() private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED) private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
@@ -94,12 +70,8 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
val isBusy = appState.map { it.isBusy } val isBusy = appState.map { it.isBusy }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val isPartialProcessedGiftWrap = appState.map { it.isPartialProcessedGiftWrap }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val isRelayListEmpty = appState.map { it.isRelayListEmpty } val isRelayListEmpty = appState.map { it.isRelayListEmpty }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val isSyncing = appState.map { it.isSyncing }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val currentUserProfile = nostr.signer.publicKeyFlow val currentUserProfile = nostr.signer.publicKeyFlow
@@ -138,39 +110,6 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
} }
private suspend fun runObserver() = coroutineScope { 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 // Observe contact list updates
launch { launch {
nostr.profiles.contactListUpdates.collect { contacts -> nostr.profiles.contactListUpdates.collect { contacts ->
@@ -245,13 +184,6 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
val currentUser = nostr.signer.getPublicKeyAsync() val currentUser = nostr.signer.getPublicKeyAsync()
if (currentUser != null) { 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 // Get all metadata for the current user
nostr.profiles.getUserMetadata() nostr.profiles.getUserMetadata()
@@ -294,11 +226,9 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
} }
fun resetInternalState() { fun resetInternalState() {
_chatRooms.value = emptySet()
_contactList.value = emptySet() _contactList.value = emptySet()
_appState.update { _appState.update {
it.copy( it.copy(
isPartialProcessedGiftWrap = false,
isRelayListEmpty = false, isRelayListEmpty = false,
) )
} }
@@ -475,164 +405,6 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
} }
} }
fun createChatRoom(to: List<PublicKey>): 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<Room>) {
_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<UnsignedEvent> {
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<EventId> = 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<EventId> = 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? { suspend fun searchByAddress(query: String): PublicKey? {
try { try {
return nostr.profiles.searchByAddress(query) return nostr.profiles.searchByAddress(query)