From 9c9d9ae88246f36f7dc36595442290d96dae75b1 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 15 Jul 2026 07:32:39 +0700 Subject: [PATCH 1/5] clean up chat screen --- .../androidMain/kotlin/su/reya/coop/App.kt | 47 ++++++++++++------- .../su/reya/coop/screens/chat/ChatScreen.kt | 41 +++++----------- .../commonMain/kotlin/su/reya/coop/Room.kt | 2 +- .../coop/viewmodel/ChatScreenViewModel.kt | 20 ++++++-- 4 files changed, 57 insertions(+), 53 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index c5ae4df..7af97d1 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -6,11 +6,14 @@ import android.os.Build import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MotionScheme import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme @@ -24,6 +27,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.core.util.Consumer import androidx.lifecycle.ViewModel @@ -230,26 +235,34 @@ fun App( NewIdentityScreen(accountViewModel) } entry { key -> - val factory = remember(key) { - object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - @Suppress("UNCHECKED_CAST") - return ChatScreenViewModel( - key.id, - key.screening, - accountRepository, - chatRepository - ) as T + val initialRoom = remember(key.id) { chatRepository.getChatRoom(key.id) } + + if (initialRoom != null) { + val factory = remember(initialRoom) { + object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return ChatScreenViewModel( + initialRoom, + key.screening, + accountRepository, + chatRepository + ) as T + } } } + ChatScreen( + viewModel( + key = key.id.toString(), + factory = factory + ), + accountViewModel + ) + } else { + // Handle the rare case where the room isn't in DB (e.g., invalid deep link) + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Room not found") + } } - ChatScreen( - viewModel( - key = key.id.toString(), - factory = factory - ), - accountViewModel - ) } entry { NewChatScreen(accountViewModel, chatViewModel) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt index 1e7fc78..cf36094 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt @@ -94,12 +94,11 @@ import rust.nostr.sdk.UnsignedEvent import su.reya.coop.LocalNavigator import su.reya.coop.LocalProfileCache import su.reya.coop.LocalSnackbarHostState -import su.reya.coop.Room import su.reya.coop.RoomUiState import su.reya.coop.Screen +import su.reya.coop.flow import su.reya.coop.formatAsGroup import su.reya.coop.shared.Avatar -import su.reya.coop.uiStateFlow import su.reya.coop.viewmodel.AccountViewModel import su.reya.coop.viewmodel.ChatScreenViewModel @@ -118,25 +117,11 @@ fun ChatScreen( val scope = rememberCoroutineScope() val listState = rememberLazyListState() - val id = viewModel.id val currentUser by viewModel.currentUser.collectAsStateWithLifecycle() - val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() - val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } } + val pubkey = currentUser?.publicKey - // Show empty screen - if (room == null) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = "Something went wrong.", - style = MaterialTheme.typography.titleMediumEmphasized, - color = MaterialTheme.colorScheme.onSurface - ) - } - return - } + val room by viewModel.room.collectAsStateWithLifecycle() + val roomState by room.flow(profileCache, pubkey).collectAsStateWithLifecycle(RoomUiState()) val loading = viewModel.loading val newOtherMessages = viewModel.newOtherMessages @@ -146,9 +131,6 @@ fun ChatScreen( val groupedMessages = remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } } - val roomState by (room as Room).uiStateFlow(profileCache, currentUser?.publicKey) - .collectAsStateWithLifecycle(RoomUiState()) - var text by remember { mutableStateOf("") } var selectedMessage by remember { mutableStateOf?>(null) } var replyingTo by remember { mutableStateOf(null) } @@ -193,9 +175,7 @@ fun ChatScreen( } } - Box( - modifier = Modifier.fillMaxSize() - ) { + Box(modifier = Modifier.fillMaxSize()) { Scaffold( modifier = Modifier.blur(blurAmount), contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime), @@ -207,7 +187,7 @@ fun ChatScreen( Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { - room?.members?.firstOrNull()?.let { pubkey -> + room.members.firstOrNull()?.let { pubkey -> navigator.navigate(Screen.Profile(pubkey.toBech32())) } } @@ -265,7 +245,7 @@ fun ChatScreen( .padding(bottom = innerPadding.calculateBottomPadding()) ) { if (requireScreening) { - room?.let { ScreenerCard(accountViewModel, it) } + ScreenerCard(accountViewModel, room) } when (messages.isNotEmpty()) { @@ -283,8 +263,7 @@ fun ChatScreen( items = messagesInGroup, key = { it.ensureId().id()?.toHex()!! } ) { event -> - val model = - rememberMessageModel(event, currentUser?.publicKey) + val model = rememberMessageModel(event, pubkey) val replyPreview = remember(model.replyEventIds, messages.size) { @@ -298,7 +277,9 @@ fun ChatScreen( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(2.dp) ) { - replyPreview?.let { ReplyPreview(it, model.isMine) } + replyPreview?.let { + ReplyPreview(it, model.isMine) + } ChatMessage( model = model, modifier = Modifier.graphicsLayer { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index 2a01f29..b49d379 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -71,7 +71,7 @@ data class RoomUiState( val isGroup: Boolean = false ) -fun Room.uiStateFlow( +fun Room.flow( profileCache: ProfileCache, currentUser: PublicKey? = null ): Flow { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatScreenViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatScreenViewModel.kt index bec9f34..a8f2f76 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatScreenViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatScreenViewModel.kt @@ -7,30 +7,40 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import rust.nostr.sdk.EventId import rust.nostr.sdk.UnsignedEvent -import su.reya.coop.Profile import su.reya.coop.Room import su.reya.coop.repository.AccountRepository import su.reya.coop.repository.ChatRepository import su.reya.coop.roomId class ChatScreenViewModel( - val id: Long, + initialRoom: Room, screening: Boolean, accountRepository: AccountRepository, private val chatRepository: ChatRepository, ) : ViewModel(), ErrorHost by chatRepository { - val currentUser: StateFlow = accountRepository.currentUserProfile - val chatRooms: StateFlow> = chatRepository.chatRooms - var loading by mutableStateOf(true) var newOtherMessages by mutableIntStateOf(0) var requireScreening by mutableStateOf(screening) val messages = mutableStateListOf() + val currentUser = accountRepository.currentUserProfile + val id = initialRoom.id + + val room: StateFlow = chatRepository.chatRooms + .map { rooms -> rooms.find { it.id == id } ?: initialRoom } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = initialRoom + ) + init { loadMessages() connect() -- 2.49.1 From 43618533a52fa543f84cfd99d4fb33a5af9aabb9 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 15 Jul 2026 08:18:05 +0700 Subject: [PATCH 2/5] improve the profile cache --- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 9 +- .../reya/coop/viewmodel/AccountViewModel.kt | 1 - .../su/reya/coop/viewmodel/ProfileCache.kt | 119 ++++++++++++------ 3 files changed, 86 insertions(+), 43 deletions(-) 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 7b38202..1a337d1 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -98,9 +98,9 @@ import su.reya.coop.RoomKind import su.reya.coop.RoomUiState import su.reya.coop.Screen import su.reya.coop.ago +import su.reya.coop.flow import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily -import su.reya.coop.uiStateFlow import su.reya.coop.viewmodel.AccountViewModel import su.reya.coop.viewmodel.ChatViewModel @@ -620,9 +620,9 @@ fun NewRequests(requests: List) { val firstRoom = requests.getOrNull(0) val secondRoom = requests.getOrNull(1) - val firstRoomState by (firstRoom as Room).uiStateFlow(profileCache) + val firstRoomState by (firstRoom as Room).flow(profileCache) .collectAsStateWithLifecycle(RoomUiState()) - val secondRoomState by (secondRoom ?: firstRoom).uiStateFlow(profileCache) + val secondRoomState by (secondRoom ?: firstRoom).flow(profileCache) .collectAsStateWithLifecycle(RoomUiState()) val supportingText = when { @@ -695,8 +695,7 @@ fun NewRequests(requests: List) { @Composable fun ChatRoom(room: Room, onClick: () -> Unit) { val profileCache = LocalProfileCache.current - val roomState by room.uiStateFlow(profileCache) - .collectAsStateWithLifecycle(RoomUiState()) + val roomState by room.flow(profileCache).collectAsStateWithLifecycle(RoomUiState()) ListItem( modifier = Modifier.clickable(onClick = onClick), diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AccountViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AccountViewModel.kt index e25a53e..8030dab 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AccountViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AccountViewModel.kt @@ -13,7 +13,6 @@ import su.reya.coop.repository.AccountState class AccountViewModel( private val repository: AccountRepository, ) : ViewModel(), ErrorHost by repository { - val state: StateFlow = repository.state val isUpdatingProfile: StateFlow = repository.isUpdatingProfile val currentUserProfile: StateFlow = repository.currentUserProfile diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ProfileCache.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ProfileCache.kt index 8e6a638..2205d80 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ProfileCache.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ProfileCache.kt @@ -1,16 +1,15 @@ package su.reya.coop.viewmodel +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull import rust.nostr.sdk.PublicKey import su.reya.coop.Profile @@ -19,27 +18,41 @@ import kotlin.time.Duration.Companion.milliseconds class ProfileCache( private val nostr: Nostr, + private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, ) : ErrorHost by createErrorHost() { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val profilesMutex = Mutex() - private val profiles = mutableMapOf>() + private val scope = CoroutineScope(SupervisorJob() + defaultDispatcher) + private val profiles = MutableStateFlow>>(emptyMap()) + private val seenPublicKeys = MutableStateFlow>(emptySet()) private val metadataRequestChannel = Channel(Channel.UNLIMITED) - private val seenPublicKeys = mutableSetOf() init { - scope.launch { runObserver() } - scope.launch { runMetadataBatching() } scope.launch { - nostr.waitUntilInitialized() - loadCacheMetadata() + try { + runObserver() + } catch (e: Exception) { + showError("Metadata observer failed: ${e.message}") + } + } + scope.launch { + try { + runMetadataBatching() + } catch (e: Exception) { + showError("Metadata batching failed: ${e.message}") + } + } + scope.launch { + try { + nostr.waitUntilInitialized() + getCachedMetadata() + } catch (e: Exception) { + showError("Failed to load initial cache: ${e.message}") + } } } - private suspend fun runObserver() = coroutineScope { - launch { - nostr.profiles.metadataUpdates.collect { (pubkey, metadata) -> - updateMetadata(pubkey, Profile(pubkey, metadata)) - } + private suspend fun runObserver() { + nostr.profiles.metadataUpdates.collect { (pubkey, metadata) -> + updateMetadata(pubkey, Profile(pubkey, metadata)) } } @@ -57,37 +70,69 @@ class ProfileCache( batch.add(nextKey) } - nostr.profiles.fetchMetadataBatch(batch.toList()) - } - } - - private suspend fun loadCacheMetadata() { - val cache = nostr.profiles.getAllCacheMetadata() - - profilesMutex.withLock { - cache.forEach { (pubkey, metadata) -> - val profile = Profile(pubkey, metadata) - profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile - seenPublicKeys.add(pubkey) + try { + nostr.profiles.fetchMetadataBatch(batch.toList()) + } catch (e: Exception) { + // Allow these keys to be requested again since the fetch failed + seenPublicKeys.update { it - batch } + println("Failed to fetch metadata batch: ${e.message}") } } } - private fun requestMetadata(pubkey: PublicKey) { - if (seenPublicKeys.add(pubkey)) { - metadataRequestChannel.trySend(pubkey) + private suspend fun getCachedMetadata() { + val cache = nostr.profiles.getAllCacheMetadata() + cache.forEach { (pubkey, metadata) -> + updateMetadata(pubkey, Profile(pubkey, metadata)) } } - private suspend fun updateMetadata(pubkey: PublicKey, profile: Profile) { - profilesMutex.withLock { - profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile + private fun requestMetadata(pubkey: PublicKey) { + var added = false + seenPublicKeys.update { current -> + if (current.contains(pubkey)) { + added = false + current + } else { + added = true + current + pubkey + } } + if (added) metadataRequestChannel.trySend(pubkey) + } + + private fun updateMetadata(pubkey: PublicKey, profile: Profile) { + profiles.update { current -> + val flow = current[pubkey] ?: MutableStateFlow(null) + flow.value = profile + if (current.containsKey(pubkey)) current else current + (pubkey to flow) + } + seenPublicKeys.update { it + pubkey } } fun getMetadata(pubkey: PublicKey): StateFlow { - val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) } - if (flow.value == null) requestMetadata(pubkey) - return flow.asStateFlow() + val currentMap = profiles.value + val existingFlow = currentMap[pubkey] + + if (existingFlow != null) { + if (existingFlow.value == null) requestMetadata(pubkey) + return existingFlow.asStateFlow() + } + + val newFlow = MutableStateFlow(null) + var resultFlow = newFlow + + profiles.update { prev -> + if (prev.containsKey(pubkey)) { + resultFlow = prev[pubkey]!! + prev + } else { + prev + (pubkey to newFlow) + } + } + + if (resultFlow.value == null) requestMetadata(pubkey) + + return resultFlow.asStateFlow() } } -- 2.49.1 From fcc30157bb3e1d414f6298809c1263e6c1d54c51 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 15 Jul 2026 17:02:55 +0700 Subject: [PATCH 3/5] Revert "clean up chat screen" This reverts commit 9c9d9ae88246f36f7dc36595442290d96dae75b1. --- .../androidMain/kotlin/su/reya/coop/App.kt | 47 +++++++------------ .../su/reya/coop/screens/chat/ChatScreen.kt | 41 +++++++++++----- .../commonMain/kotlin/su/reya/coop/Room.kt | 2 +- .../coop/viewmodel/ChatScreenViewModel.kt | 20 ++------ 4 files changed, 53 insertions(+), 57 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 7af97d1..c5ae4df 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -6,14 +6,11 @@ import android.os.Build import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MotionScheme import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme @@ -27,8 +24,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.core.util.Consumer import androidx.lifecycle.ViewModel @@ -235,34 +230,26 @@ fun App( NewIdentityScreen(accountViewModel) } entry { key -> - val initialRoom = remember(key.id) { chatRepository.getChatRoom(key.id) } - - if (initialRoom != null) { - val factory = remember(initialRoom) { - object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - return ChatScreenViewModel( - initialRoom, - key.screening, - accountRepository, - chatRepository - ) as T - } + val factory = remember(key) { + object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return ChatScreenViewModel( + key.id, + key.screening, + accountRepository, + chatRepository + ) as T } } - ChatScreen( - viewModel( - key = key.id.toString(), - factory = factory - ), - accountViewModel - ) - } else { - // Handle the rare case where the room isn't in DB (e.g., invalid deep link) - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Room not found") - } } + ChatScreen( + viewModel( + key = key.id.toString(), + factory = factory + ), + accountViewModel + ) } entry { NewChatScreen(accountViewModel, chatViewModel) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt index cf36094..1e7fc78 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt @@ -94,11 +94,12 @@ import rust.nostr.sdk.UnsignedEvent import su.reya.coop.LocalNavigator import su.reya.coop.LocalProfileCache import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.Room import su.reya.coop.RoomUiState import su.reya.coop.Screen -import su.reya.coop.flow import su.reya.coop.formatAsGroup import su.reya.coop.shared.Avatar +import su.reya.coop.uiStateFlow import su.reya.coop.viewmodel.AccountViewModel import su.reya.coop.viewmodel.ChatScreenViewModel @@ -117,11 +118,25 @@ fun ChatScreen( val scope = rememberCoroutineScope() val listState = rememberLazyListState() + val id = viewModel.id val currentUser by viewModel.currentUser.collectAsStateWithLifecycle() - val pubkey = currentUser?.publicKey + val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() + val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } } - val room by viewModel.room.collectAsStateWithLifecycle() - val roomState by room.flow(profileCache, pubkey).collectAsStateWithLifecycle(RoomUiState()) + // Show empty screen + if (room == null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Something went wrong.", + style = MaterialTheme.typography.titleMediumEmphasized, + color = MaterialTheme.colorScheme.onSurface + ) + } + return + } val loading = viewModel.loading val newOtherMessages = viewModel.newOtherMessages @@ -131,6 +146,9 @@ fun ChatScreen( val groupedMessages = remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } } + val roomState by (room as Room).uiStateFlow(profileCache, currentUser?.publicKey) + .collectAsStateWithLifecycle(RoomUiState()) + var text by remember { mutableStateOf("") } var selectedMessage by remember { mutableStateOf?>(null) } var replyingTo by remember { mutableStateOf(null) } @@ -175,7 +193,9 @@ fun ChatScreen( } } - Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier.fillMaxSize() + ) { Scaffold( modifier = Modifier.blur(blurAmount), contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime), @@ -187,7 +207,7 @@ fun ChatScreen( Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { - room.members.firstOrNull()?.let { pubkey -> + room?.members?.firstOrNull()?.let { pubkey -> navigator.navigate(Screen.Profile(pubkey.toBech32())) } } @@ -245,7 +265,7 @@ fun ChatScreen( .padding(bottom = innerPadding.calculateBottomPadding()) ) { if (requireScreening) { - ScreenerCard(accountViewModel, room) + room?.let { ScreenerCard(accountViewModel, it) } } when (messages.isNotEmpty()) { @@ -263,7 +283,8 @@ fun ChatScreen( items = messagesInGroup, key = { it.ensureId().id()?.toHex()!! } ) { event -> - val model = rememberMessageModel(event, pubkey) + val model = + rememberMessageModel(event, currentUser?.publicKey) val replyPreview = remember(model.replyEventIds, messages.size) { @@ -277,9 +298,7 @@ fun ChatScreen( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(2.dp) ) { - replyPreview?.let { - ReplyPreview(it, model.isMine) - } + replyPreview?.let { ReplyPreview(it, model.isMine) } ChatMessage( model = model, modifier = Modifier.graphicsLayer { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index b49d379..2a01f29 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -71,7 +71,7 @@ data class RoomUiState( val isGroup: Boolean = false ) -fun Room.flow( +fun Room.uiStateFlow( profileCache: ProfileCache, currentUser: PublicKey? = null ): Flow { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatScreenViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatScreenViewModel.kt index a8f2f76..bec9f34 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatScreenViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatScreenViewModel.kt @@ -7,40 +7,30 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import rust.nostr.sdk.EventId import rust.nostr.sdk.UnsignedEvent +import su.reya.coop.Profile import su.reya.coop.Room import su.reya.coop.repository.AccountRepository import su.reya.coop.repository.ChatRepository import su.reya.coop.roomId class ChatScreenViewModel( - initialRoom: Room, + val id: Long, screening: Boolean, accountRepository: AccountRepository, private val chatRepository: ChatRepository, ) : ViewModel(), ErrorHost by chatRepository { + val currentUser: StateFlow = accountRepository.currentUserProfile + val chatRooms: StateFlow> = chatRepository.chatRooms + var loading by mutableStateOf(true) var newOtherMessages by mutableIntStateOf(0) var requireScreening by mutableStateOf(screening) val messages = mutableStateListOf() - val currentUser = accountRepository.currentUserProfile - val id = initialRoom.id - - val room: StateFlow = chatRepository.chatRooms - .map { rooms -> rooms.find { it.id == id } ?: initialRoom } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = initialRoom - ) - init { loadMessages() connect() -- 2.49.1 From 2d830b778ffb0a1319bc9aa9b575575be37d9bf4 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 15 Jul 2026 17:57:55 +0700 Subject: [PATCH 4/5] clean up --- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 8 +++--- .../su/reya/coop/screens/NewChatScreen.kt | 2 +- .../commonMain/kotlin/su/reya/coop/Room.kt | 9 ++----- .../kotlin/su/reya/coop/nostr/Messaging.kt | 8 ++++++ .../kotlin/su/reya/coop/nostr/Nostr.kt | 10 +------ .../su/reya/coop/nostr/ProfileManager.kt | 27 +++++++++++++++++++ .../su/reya/coop/repository/ChatRepository.kt | 9 ++++--- .../su/reya/coop/viewmodel/ProfileCache.kt | 2 +- 8 files changed, 50 insertions(+), 25 deletions(-) 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 1a337d1..4220715 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -98,9 +98,9 @@ import su.reya.coop.RoomKind import su.reya.coop.RoomUiState import su.reya.coop.Screen import su.reya.coop.ago -import su.reya.coop.flow import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily +import su.reya.coop.uiStateFlow import su.reya.coop.viewmodel.AccountViewModel import su.reya.coop.viewmodel.ChatViewModel @@ -620,9 +620,9 @@ fun NewRequests(requests: List) { val firstRoom = requests.getOrNull(0) val secondRoom = requests.getOrNull(1) - val firstRoomState by (firstRoom as Room).flow(profileCache) + val firstRoomState by (firstRoom as Room).uiStateFlow(profileCache) .collectAsStateWithLifecycle(RoomUiState()) - val secondRoomState by (secondRoom ?: firstRoom).flow(profileCache) + val secondRoomState by (secondRoom ?: firstRoom).uiStateFlow(profileCache) .collectAsStateWithLifecycle(RoomUiState()) val supportingText = when { @@ -695,7 +695,7 @@ fun NewRequests(requests: List) { @Composable fun ChatRoom(room: Room, onClick: () -> Unit) { val profileCache = LocalProfileCache.current - val roomState by room.flow(profileCache).collectAsStateWithLifecycle(RoomUiState()) + val roomState by room.uiStateFlow(profileCache).collectAsStateWithLifecycle(RoomUiState()) ListItem( modifier = Modifier.clickable(onClick = onClick), 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 a8d1076..c28de53 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt @@ -397,7 +397,7 @@ fun ContactListItem( supportingContent = { Text(text = pubkey.short()) }, content = { Text( - text = profile?.name ?: "", + text = profile?.name ?: "Unknown", style = MaterialTheme.typography.titleMediumEmphasized, ) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index 2a01f29..d761aad 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -112,13 +112,8 @@ fun UnsignedEvent.roomId(): Long { val pubkeys: MutableList = mutableListOf() pubkeys.add(this.author()) pubkeys.addAll(this.tags().publicKeys()) - - // Sort and hash the list of public keys - val sortedUniqueKeys = pubkeys - .distinctBy { it.toBech32() } - .sortedBy { it.toBech32() } - - return sortedUniqueKeys.hashCode().toLong() + + return pubkeys.map { it.toBech32() }.distinct().sorted().hashCode().toLong() } fun Timestamp.formatAsTime(): String { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt index 1b86a7b..a3ac909 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt @@ -73,6 +73,8 @@ class MessageManager(private val nostr: Nostr) { target = ReqTarget.manual(target), id = "gift-wraps" ) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { throw IllegalStateException("Failed to fetch user messages: ${e.message}", e) } @@ -225,6 +227,8 @@ class MessageManager(private val nostr: Nostr) { // Filter out events without public keys (receivers) ?.filter { it.tags().publicKeys().isNotEmpty() } ?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { throw IllegalStateException("Failed to get chat room messages: ${e.message}", e) } @@ -248,6 +252,8 @@ class MessageManager(private val nostr: Nostr) { connectMsgRelays(event) } } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { throw IllegalStateException("Failed to fetch relays: ${e.message}", e) } @@ -340,6 +346,8 @@ class MessageManager(private val nostr: Nostr) { } } } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { throw IllegalStateException("Failed to send message: ${e.message}", e) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt index e7617d4..9d90f55 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt @@ -9,9 +9,9 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withTimeoutOrNull import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.Client import rust.nostr.sdk.ClientBuilder @@ -108,14 +108,6 @@ class Nostr( relays.connectBootstrapRelays() } - suspend fun reconnect() { - relays.reconnect() - } - - suspend fun disconnect() { - relays.disconnect() - } - suspend fun prune() { try { client?.database()?.wipe() diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt index f228757..81439d6 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt @@ -27,6 +27,7 @@ import rust.nostr.sdk.ReqTarget import rust.nostr.sdk.SendEventTarget import rust.nostr.sdk.SubscribeAutoCloseOptions import rust.nostr.sdk.Timestamp +import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration class ProfileManager(private val nostr: Nostr) { @@ -80,6 +81,8 @@ class ProfileManager(private val nostr: Nostr) { val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) client?.subscribe(target = target, id = "user-metadata", closeOn = opts) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e) } @@ -92,6 +95,8 @@ class ProfileManager(private val nostr: Nostr) { val relays = RelayManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) } client?.sync(filter, relays) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { println("Failed to sync mutual contacts: ${e.message}") } @@ -174,6 +179,8 @@ class ProfileManager(private val nostr: Nostr) { ) return newMetadata + } catch (e: CancellationException) { + throw e } catch (e: Exception) { throw IllegalStateException("Failed to update identity: ${e.message}", e) } @@ -186,6 +193,8 @@ class ProfileManager(private val nostr: Nostr) { val event = client?.database()?.query(filter)?.first() ?: return null Metadata.fromJson(event.content()) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { println("Failed to get latest metadata: ${e.message}") null @@ -208,6 +217,8 @@ class ProfileManager(private val nostr: Nostr) { } return results + } catch (e: CancellationException) { + throw e } catch (e: Exception) { println("Failed to get all cache metadata: ${e.message}") return emptyMap() @@ -232,6 +243,8 @@ class ProfileManager(private val nostr: Nostr) { } client?.subscribe(target = ReqTarget.manual(target), closeOn = opts) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e) } @@ -247,6 +260,8 @@ class ProfileManager(private val nostr: Nostr) { target = SendEventTarget.broadcast(), ackPolicy = AckPolicy.none(), ) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { throw IllegalStateException("Failed to set contact list: ${e.message}", e) } @@ -258,6 +273,8 @@ class ProfileManager(private val nostr: Nostr) { val bodyString: String = response.body() return Nip05Profile.fromJson(address, bodyString) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e) } @@ -269,6 +286,8 @@ class ProfileManager(private val nostr: Nostr) { val profile = profileFromAddress(httpClient, address) return profile.publicKey() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { throw IllegalStateException("Failed to search address: ${e.message}", e) } @@ -304,6 +323,8 @@ class ProfileManager(private val nostr: Nostr) { } return results + } catch (e: CancellationException) { + throw e } catch (e: Exception) { throw IllegalStateException("Failed to search nostr: ${e.message}", e) } @@ -323,6 +344,8 @@ class ProfileManager(private val nostr: Nostr) { ) return events?.first()?.createdAt() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { throw IllegalStateException("Failed to get latest activity: ${e.message}", e) } @@ -340,6 +363,8 @@ class ProfileManager(private val nostr: Nostr) { val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf() return pubkeys.contains(pubkey) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e) } @@ -361,6 +386,8 @@ class ProfileManager(private val nostr: Nostr) { } return contacts.toSet() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt index 6dbe60a..3968af5 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt @@ -51,13 +51,16 @@ class ChatRepository( ) val newEvents = _newEvents.asSharedFlow() - val chatRooms = state.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } } + val chatRooms = state + .map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } } .stateIn(scope, SharingStarted.WhileSubscribed(5000), emptyList()) - val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } + val isSyncing = nostr.messages.messageSyncState + .map { it.isSyncing } .stateIn(scope, SharingStarted.WhileSubscribed(5000), false) - val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap } + val isPartialProcessedGiftWrap = state + .map { it.isPartialProcessedGiftWrap } .stateIn(scope, SharingStarted.WhileSubscribed(5000), false) init { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ProfileCache.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ProfileCache.kt index 2205d80..420043a 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ProfileCache.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ProfileCache.kt @@ -18,7 +18,7 @@ import kotlin.time.Duration.Companion.milliseconds class ProfileCache( private val nostr: Nostr, - private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, + defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, ) : ErrorHost by createErrorHost() { private val scope = CoroutineScope(SupervisorJob() + defaultDispatcher) private val profiles = MutableStateFlow>>(emptyMap()) -- 2.49.1 From 265bc0cbf7ea4cef47ae2e1282a93dc978066f7c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 15 Jul 2026 19:40:57 +0700 Subject: [PATCH 5/5] . --- .../kotlin/su/reya/coop/nostr/Messaging.kt | 8 +-- .../kotlin/su/reya/coop/nostr/Nostr.kt | 10 ---- .../kotlin/su/reya/coop/nostr/RelayManager.kt | 4 +- .../reya/coop/repository/AccountRepository.kt | 6 +- .../su/reya/coop/repository/ChatRepository.kt | 60 +++++++++---------- 5 files changed, 35 insertions(+), 53 deletions(-) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt index a3ac909..3e3a002 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt @@ -39,12 +39,11 @@ class MessageManager(private val nostr: Nostr) { private val client: Client? get() = nostr.client private val signer: UniversalSigner get() = nostr.signer - val sentEvents: MutableMap> = mutableMapOf() - val rumorMap: MutableMap = mutableMapOf() - private val _messageSyncState = MutableStateFlow(MessageSyncState()) val messageSyncState = _messageSyncState.asStateFlow() + val rumorMap: MutableMap = mutableMapOf() + fun updateSyncState(update: (MessageSyncState) -> MessageSyncState) { _messageSyncState.update(update) } @@ -333,9 +332,6 @@ class MessageManager(private val nostr: Nostr) { ) if (output != null) { - // Keep track of sent events - sentEvents[output.id] = emptyList() - // Keep track of rumor IDs val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null") rumorMap[id] = output.id diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt index 9d90f55..2580334 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt @@ -174,8 +174,6 @@ class Nostr( when (notification) { is ClientNotification.Message -> { - val relayUrl = notification.relayUrl - when (val message = notification.message.asEnum()) { is RelayMessageEnum.EventMsg -> { val event = message.event @@ -229,14 +227,6 @@ class Nostr( } } - is RelayMessageEnum.Ok -> { - if (messages.sentEvents.containsKey(message.eventId)) { - val currentRelays = - messages.sentEvents[message.eventId] ?: emptyList() - messages.sentEvents[message.eventId] = currentRelays + relayUrl - } - } - else -> { /* Ignore other message types */ } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt index 12482bd..5242dad 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt @@ -130,7 +130,7 @@ class RelayManager(private val nostr: Nostr) { } } - suspend fun setRelaylist(relays: Map) { + suspend fun setRelayList(relays: Map) { try { val event = EventBuilder.relayList(relays).finalizeAsync(signer) @@ -143,7 +143,7 @@ class RelayManager(private val nostr: Nostr) { throw IllegalStateException("Failed to set msg relays: ${e.message}", e) } } - + suspend fun setMsgRelays(urls: List) { try { val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/repository/AccountRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/AccountRepository.kt index e7562c7..f92956f 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/repository/AccountRepository.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/AccountRepository.kt @@ -511,7 +511,7 @@ class AccountRepository( val relays = currentUserRelayListInternal().toMutableMap() relays[relayUrl] = RelayMetadata.WRITE - nostr.relays.setRelaylist(relays) + nostr.relays.setRelayList(relays) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -525,7 +525,7 @@ class AccountRepository( val relays = currentUserRelayListInternal().toMutableMap() relays[relayUrl] = RelayMetadata.READ - nostr.relays.setRelaylist(relays) + nostr.relays.setRelayList(relays) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -539,7 +539,7 @@ class AccountRepository( val relays = currentUserRelayListInternal().toMutableMap() relays.remove(relayUrl) - nostr.relays.setRelaylist(relays) + nostr.relays.setRelayList(relays) } catch (e: Exception) { showError("Error: ${e.message}") } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt index 3968af5..f26410b 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt @@ -85,18 +85,7 @@ class ChatRepository( // Observe new messages launch { nostr.newEvents.collect { event -> - val roomId = event.roomId() - val existingRoom = _state.value.rooms[roomId] - - if (existingRoom == null) { - val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect - val newRoom = Room.new(event, currentUser) - _state.update { it.copy(rooms = it.rooms + (newRoom.id to newRoom)) } - } else { - updateRoomList(roomId, event) - } - - _newEvents.tryEmit(event) + updateRoomState(event) } } } @@ -197,9 +186,10 @@ class ChatRepository( content = message, subject = room.subject, replies = replies, - onRumorCreated = { event -> - updateRoomList(roomId, event) - scope.launch(defaultDispatcher) { _newEvents.tryEmit(event) } + onRumorCreated = { + scope.launch(defaultDispatcher) { + updateRoomState(it, roomId) + } }, ) } catch (e: Exception) { @@ -226,26 +216,32 @@ class ChatRepository( } } - fun isMessageSent(id: EventId): Boolean { - val giftWrapId = nostr.messages.rumorMap[id] + private suspend fun updateRoomState(event: UnsignedEvent, roomId: Long = event.roomId()) { + val currentUser = nostr.signer.getPublicKeyAsync() ?: return - if (giftWrapId != null) { - val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false - return isSent - } else { - return false - } - } - - private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) { _state.update { currentState -> - val room = currentState.rooms[roomId] ?: return@update currentState - val updatedRoom = room.copy( - lastMessage = newMessage.content(), - createdAt = newMessage.createdAt() - ) - currentState.copy(rooms = currentState.rooms + (roomId to updatedRoom)) + val rooms = currentState.rooms.toMutableMap() + val existingRoom = rooms[roomId] + + if (existingRoom == null) { + // New room discovery + val newRoom = Room.new(event, currentUser, roomId) + rooms[newRoom.id] = newRoom + } else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) { + // Only update preview if message is newer (handles sync/late arrivals) + rooms[roomId] = existingRoom.copy( + lastMessage = event.content(), + createdAt = event.createdAt() + ) + } else { + // Don't update the room list state for older messages + return@update currentState + } + currentState.copy(rooms = rooms) } + + // Notify subscribers about the new event (for the active chat screen) + _newEvents.tryEmit(event) } fun resetInternalState() { -- 2.49.1