2 Commits

Author SHA1 Message Date
43618533a5 improve the profile cache 2026-07-15 08:18:05 +07:00
9c9d9ae882 clean up chat screen 2026-07-15 07:32:39 +07:00
7 changed files with 143 additions and 96 deletions

View File

@@ -6,11 +6,14 @@ import android.os.Build
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.isSystemInDarkTheme 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.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme import androidx.compose.material3.MotionScheme
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
@@ -24,6 +27,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.util.Consumer import androidx.core.util.Consumer
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@@ -230,26 +235,34 @@ fun App(
NewIdentityScreen(accountViewModel) NewIdentityScreen(accountViewModel)
} }
entry<Screen.Chat> { key -> entry<Screen.Chat> { key ->
val factory = remember(key) { val initialRoom = remember(key.id) { chatRepository.getChatRoom(key.id) }
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { if (initialRoom != null) {
@Suppress("UNCHECKED_CAST") val factory = remember(initialRoom) {
return ChatScreenViewModel( object : ViewModelProvider.Factory {
key.id, override fun <T : ViewModel> create(modelClass: Class<T>): T {
key.screening, return ChatScreenViewModel(
accountRepository, initialRoom,
chatRepository key.screening,
) as T accountRepository,
chatRepository
) as T
}
} }
} }
ChatScreen(
viewModel<ChatScreenViewModel>(
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<ChatScreenViewModel>(
key = key.id.toString(),
factory = factory
),
accountViewModel
)
} }
entry<Screen.NewChat> { entry<Screen.NewChat> {
NewChatScreen(accountViewModel, chatViewModel) NewChatScreen(accountViewModel, chatViewModel)

View File

@@ -98,9 +98,9 @@ import su.reya.coop.RoomKind
import su.reya.coop.RoomUiState import su.reya.coop.RoomUiState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.ago import su.reya.coop.ago
import su.reya.coop.flow
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.uiStateFlow
import su.reya.coop.viewmodel.AccountViewModel import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.ChatViewModel
@@ -620,9 +620,9 @@ fun NewRequests(requests: List<Room>) {
val firstRoom = requests.getOrNull(0) val firstRoom = requests.getOrNull(0)
val secondRoom = requests.getOrNull(1) val secondRoom = requests.getOrNull(1)
val firstRoomState by (firstRoom as Room).uiStateFlow(profileCache) val firstRoomState by (firstRoom as Room).flow(profileCache)
.collectAsStateWithLifecycle(RoomUiState()) .collectAsStateWithLifecycle(RoomUiState())
val secondRoomState by (secondRoom ?: firstRoom).uiStateFlow(profileCache) val secondRoomState by (secondRoom ?: firstRoom).flow(profileCache)
.collectAsStateWithLifecycle(RoomUiState()) .collectAsStateWithLifecycle(RoomUiState())
val supportingText = when { val supportingText = when {
@@ -695,8 +695,7 @@ fun NewRequests(requests: List<Room>) {
@Composable @Composable
fun ChatRoom(room: Room, onClick: () -> Unit) { fun ChatRoom(room: Room, onClick: () -> Unit) {
val profileCache = LocalProfileCache.current val profileCache = LocalProfileCache.current
val roomState by room.uiStateFlow(profileCache) val roomState by room.flow(profileCache).collectAsStateWithLifecycle(RoomUiState())
.collectAsStateWithLifecycle(RoomUiState())
ListItem( ListItem(
modifier = Modifier.clickable(onClick = onClick), modifier = Modifier.clickable(onClick = onClick),

View File

@@ -94,12 +94,11 @@ import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalProfileCache import su.reya.coop.LocalProfileCache
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room
import su.reya.coop.RoomUiState import su.reya.coop.RoomUiState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.flow
import su.reya.coop.formatAsGroup import su.reya.coop.formatAsGroup
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.uiStateFlow
import su.reya.coop.viewmodel.AccountViewModel import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatScreenViewModel import su.reya.coop.viewmodel.ChatScreenViewModel
@@ -118,25 +117,11 @@ fun ChatScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val id = viewModel.id
val currentUser by viewModel.currentUser.collectAsStateWithLifecycle() val currentUser by viewModel.currentUser.collectAsStateWithLifecycle()
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() val pubkey = currentUser?.publicKey
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
// Show empty screen val room by viewModel.room.collectAsStateWithLifecycle()
if (room == null) { val roomState by room.flow(profileCache, pubkey).collectAsStateWithLifecycle(RoomUiState())
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 loading = viewModel.loading
val newOtherMessages = viewModel.newOtherMessages val newOtherMessages = viewModel.newOtherMessages
@@ -146,9 +131,6 @@ fun ChatScreen(
val groupedMessages = val groupedMessages =
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } } 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 text by remember { mutableStateOf("") }
var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) } var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) }
var replyingTo by remember { mutableStateOf<MessageModel?>(null) } var replyingTo by remember { mutableStateOf<MessageModel?>(null) }
@@ -193,9 +175,7 @@ fun ChatScreen(
} }
} }
Box( Box(modifier = Modifier.fillMaxSize()) {
modifier = Modifier.fillMaxSize()
) {
Scaffold( Scaffold(
modifier = Modifier.blur(blurAmount), modifier = Modifier.blur(blurAmount),
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime), contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
@@ -207,7 +187,7 @@ fun ChatScreen(
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { modifier = Modifier.clickable {
room?.members?.firstOrNull()?.let { pubkey -> room.members.firstOrNull()?.let { pubkey ->
navigator.navigate(Screen.Profile(pubkey.toBech32())) navigator.navigate(Screen.Profile(pubkey.toBech32()))
} }
} }
@@ -265,7 +245,7 @@ fun ChatScreen(
.padding(bottom = innerPadding.calculateBottomPadding()) .padding(bottom = innerPadding.calculateBottomPadding())
) { ) {
if (requireScreening) { if (requireScreening) {
room?.let { ScreenerCard(accountViewModel, it) } ScreenerCard(accountViewModel, room)
} }
when (messages.isNotEmpty()) { when (messages.isNotEmpty()) {
@@ -283,8 +263,7 @@ fun ChatScreen(
items = messagesInGroup, items = messagesInGroup,
key = { it.ensureId().id()?.toHex()!! } key = { it.ensureId().id()?.toHex()!! }
) { event -> ) { event ->
val model = val model = rememberMessageModel(event, pubkey)
rememberMessageModel(event, currentUser?.publicKey)
val replyPreview = val replyPreview =
remember(model.replyEventIds, messages.size) { remember(model.replyEventIds, messages.size) {
@@ -298,7 +277,9 @@ fun ChatScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
replyPreview?.let { ReplyPreview(it, model.isMine) } replyPreview?.let {
ReplyPreview(it, model.isMine)
}
ChatMessage( ChatMessage(
model = model, model = model,
modifier = Modifier.graphicsLayer { modifier = Modifier.graphicsLayer {

View File

@@ -71,7 +71,7 @@ data class RoomUiState(
val isGroup: Boolean = false val isGroup: Boolean = false
) )
fun Room.uiStateFlow( fun Room.flow(
profileCache: ProfileCache, profileCache: ProfileCache,
currentUser: PublicKey? = null currentUser: PublicKey? = null
): Flow<RoomUiState> { ): Flow<RoomUiState> {

View File

@@ -13,7 +13,6 @@ import su.reya.coop.repository.AccountState
class AccountViewModel( class AccountViewModel(
private val repository: AccountRepository, private val repository: AccountRepository,
) : ViewModel(), ErrorHost by repository { ) : ViewModel(), ErrorHost by repository {
val state: StateFlow<AccountState> = repository.state val state: StateFlow<AccountState> = repository.state
val isUpdatingProfile: StateFlow<Boolean> = repository.isUpdatingProfile val isUpdatingProfile: StateFlow<Boolean> = repository.isUpdatingProfile
val currentUserProfile: StateFlow<Profile?> = repository.currentUserProfile val currentUserProfile: StateFlow<Profile?> = repository.currentUserProfile

View File

@@ -7,30 +7,40 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import rust.nostr.sdk.EventId import rust.nostr.sdk.EventId
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.Profile
import su.reya.coop.Room import su.reya.coop.Room
import su.reya.coop.repository.AccountRepository import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.ChatRepository import su.reya.coop.repository.ChatRepository
import su.reya.coop.roomId import su.reya.coop.roomId
class ChatScreenViewModel( class ChatScreenViewModel(
val id: Long, initialRoom: Room,
screening: Boolean, screening: Boolean,
accountRepository: AccountRepository, accountRepository: AccountRepository,
private val chatRepository: ChatRepository, private val chatRepository: ChatRepository,
) : ViewModel(), ErrorHost by chatRepository { ) : ViewModel(), ErrorHost by chatRepository {
val currentUser: StateFlow<Profile?> = accountRepository.currentUserProfile
val chatRooms: StateFlow<List<Room>> = chatRepository.chatRooms
var loading by mutableStateOf(true) var loading by mutableStateOf(true)
var newOtherMessages by mutableIntStateOf(0) var newOtherMessages by mutableIntStateOf(0)
var requireScreening by mutableStateOf(screening) var requireScreening by mutableStateOf(screening)
val messages = mutableStateListOf<UnsignedEvent>() val messages = mutableStateListOf<UnsignedEvent>()
val currentUser = accountRepository.currentUserProfile
val id = initialRoom.id
val room: StateFlow<Room> = chatRepository.chatRooms
.map { rooms -> rooms.find { it.id == id } ?: initialRoom }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = initialRoom
)
init { init {
loadMessages() loadMessages()
connect() connect()

View File

@@ -1,16 +1,15 @@
package su.reya.coop.viewmodel package su.reya.coop.viewmodel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.Profile import su.reya.coop.Profile
@@ -19,27 +18,41 @@ import kotlin.time.Duration.Companion.milliseconds
class ProfileCache( class ProfileCache(
private val nostr: Nostr, private val nostr: Nostr,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ErrorHost by createErrorHost() { ) : ErrorHost by createErrorHost() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + defaultDispatcher)
private val profilesMutex = Mutex() private val profiles = MutableStateFlow<Map<PublicKey, MutableStateFlow<Profile?>>>(emptyMap())
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>() private val seenPublicKeys = MutableStateFlow<Set<PublicKey>>(emptySet())
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED) private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>()
init { init {
scope.launch { runObserver() }
scope.launch { runMetadataBatching() }
scope.launch { scope.launch {
nostr.waitUntilInitialized() try {
loadCacheMetadata() 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 { private suspend fun runObserver() {
launch { nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) -> updateMetadata(pubkey, Profile(pubkey, metadata))
updateMetadata(pubkey, Profile(pubkey, metadata))
}
} }
} }
@@ -57,37 +70,69 @@ class ProfileCache(
batch.add(nextKey) batch.add(nextKey)
} }
nostr.profiles.fetchMetadataBatch(batch.toList()) try {
} nostr.profiles.fetchMetadataBatch(batch.toList())
} } catch (e: Exception) {
// Allow these keys to be requested again since the fetch failed
private suspend fun loadCacheMetadata() { seenPublicKeys.update { it - batch }
val cache = nostr.profiles.getAllCacheMetadata() println("Failed to fetch metadata batch: ${e.message}")
profilesMutex.withLock {
cache.forEach { (pubkey, metadata) ->
val profile = Profile(pubkey, metadata)
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
seenPublicKeys.add(pubkey)
} }
} }
} }
private fun requestMetadata(pubkey: PublicKey) { private suspend fun getCachedMetadata() {
if (seenPublicKeys.add(pubkey)) { val cache = nostr.profiles.getAllCacheMetadata()
metadataRequestChannel.trySend(pubkey) cache.forEach { (pubkey, metadata) ->
updateMetadata(pubkey, Profile(pubkey, metadata))
} }
} }
private suspend fun updateMetadata(pubkey: PublicKey, profile: Profile) { private fun requestMetadata(pubkey: PublicKey) {
profilesMutex.withLock { var added = false
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile 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<Profile?>(null)
flow.value = profile
if (current.containsKey(pubkey)) current else current + (pubkey to flow)
}
seenPublicKeys.update { it + pubkey }
} }
fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> { fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> {
val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) } val currentMap = profiles.value
if (flow.value == null) requestMetadata(pubkey) val existingFlow = currentMap[pubkey]
return flow.asStateFlow()
if (existingFlow != null) {
if (existingFlow.value == null) requestMetadata(pubkey)
return existingFlow.asStateFlow()
}
val newFlow = MutableStateFlow<Profile?>(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()
} }
} }