Merge pull request #21 from garfiec/refactor/nav-host-viewmodel_stage3

refactor: NavHostViewModel stage 3 — extract conversation list state holder
This commit is contained in:
Garfie Chiu 2026-04-07 20:09:12 -05:00 committed by GitHub
commit d942fbf3a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 184 additions and 116 deletions

View file

@ -0,0 +1,162 @@
package com.garfiec.librechat.shared.navigation
import co.touchlab.kermit.Logger
import com.garfiec.librechat.core.common.extensions.toInstantOrNull
import com.garfiec.librechat.core.common.extensions.toRelativeDateGroup
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.data.repository.ConversationRepository
import com.garfiec.librechat.core.model.Conversation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
class ConversationListStateHolder(
private val conversationRepository: ConversationRepository,
private val scope: CoroutineScope,
) {
companion object {
private const val SEARCH_DEBOUNCE_MS = 300L
}
private val _recentConversations = MutableStateFlow<List<Conversation>>(emptyList())
val recentConversations: StateFlow<List<Conversation>> = _recentConversations.asStateFlow()
private val _groupedConversations = MutableStateFlow<List<Pair<String, List<Conversation>>>>(emptyList())
val groupedConversations: StateFlow<List<Pair<String, List<Conversation>>>> = _groupedConversations.asStateFlow()
private val _activeConversationId = MutableStateFlow<String?>(null)
val activeConversationId: StateFlow<String?> = _activeConversationId.asStateFlow()
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
private var nextCursor: String? = null
private val _hasMore = MutableStateFlow(true)
val hasMore: StateFlow<Boolean> = _hasMore.asStateFlow()
private val _isLoadingMore = MutableStateFlow(false)
val isLoadingMore: StateFlow<Boolean> = _isLoadingMore.asStateFlow()
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
init {
observeConversations()
observeSearchQuery()
loadInitialConversations()
}
private fun observeConversations() {
scope.launch {
try {
conversationRepository.observeConversations().collect { result ->
if (result is Result.Success) {
_recentConversations.value = result.data
if (_searchQuery.value.isBlank()) {
_groupedConversations.value = groupConversationsByDate(result.data)
}
}
}
} catch (e: Exception) {
Logger.w(e) { "Failed to observe conversations" }
}
}
}
private fun loadInitialConversations() {
scope.launch {
loadPage(cursor = null)
}
}
private suspend fun loadPage(cursor: String?): Boolean {
return try {
val result = conversationRepository.loadNextPage(cursor = cursor)
if (result is Result.Success) {
nextCursor = result.data
_hasMore.value = result.data != null
true
} else {
Logger.w { "Failed to load conversations page" }
false
}
} catch (e: Exception) {
Logger.w(e) { "Failed to load conversations page" }
false
}
}
fun loadMoreConversations() {
if (_isLoadingMore.value || !_hasMore.value) return
_isLoadingMore.value = true
scope.launch {
loadPage(cursor = nextCursor)
_isLoadingMore.value = false
}
}
fun refreshConversations() {
_isRefreshing.value = true
scope.launch {
nextCursor = null
_hasMore.value = true
loadPage(cursor = null)
_isRefreshing.value = false
}
}
fun setActiveConversation(conversationId: String?) {
_activeConversationId.value = conversationId
}
fun onSearchQueryChanged(query: String) {
_searchQuery.value = query
if (query.isBlank()) {
_groupedConversations.value = groupConversationsByDate(_recentConversations.value)
}
}
@OptIn(FlowPreview::class)
private fun observeSearchQuery() {
scope.launch {
_searchQuery
.filter { it.isNotBlank() }
.debounce(SEARCH_DEBOUNCE_MS)
.collectLatest { query ->
val filtered = _recentConversations.value.filter { conversation ->
conversation.title?.contains(query, ignoreCase = true) == true
}
_groupedConversations.value = groupConversationsByDate(filtered)
}
}
}
fun reset() {
_recentConversations.value = emptyList()
_groupedConversations.value = emptyList()
_activeConversationId.value = null
_searchQuery.value = ""
}
private fun groupConversationsByDate(
conversations: List<Conversation>,
): List<Pair<String, List<Conversation>>> {
if (conversations.isEmpty()) return emptyList()
return conversations
.groupBy { conversation ->
conversation.updatedAt
?.toInstantOrNull()
?.toRelativeDateGroup()
?: "Unknown"
}
.toList()
}
}

View file

@ -4,8 +4,7 @@ import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.garfiec.librechat.core.common.extensions.toInstantOrNull import com.garfiec.librechat.core.common.extensions.toInstantOrNull
import com.garfiec.librechat.core.common.extensions.toRelativeDateGroup import com.garfiec.librechat.core.common.extensions.formatMonthAbbrev
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.data.repository.AuthRepository import com.garfiec.librechat.core.data.repository.AuthRepository
import com.garfiec.librechat.core.data.repository.BannerRepository import com.garfiec.librechat.core.data.repository.BannerRepository
import com.garfiec.librechat.core.data.repository.ConfigRepository import com.garfiec.librechat.core.data.repository.ConfigRepository
@ -19,16 +18,11 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.garfiec.librechat.core.common.extensions.firstBlocking import com.garfiec.librechat.core.common.extensions.firstBlocking
import com.garfiec.librechat.core.common.extensions.formatMonthAbbrev
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
@ -66,33 +60,22 @@ class NavHostViewModel(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
bannerRepository: BannerRepository, bannerRepository: BannerRepository,
configRepository: ConfigRepository, configRepository: ConfigRepository,
private val conversationRepository: ConversationRepository, conversationRepository: ConversationRepository,
private val tokenManager: TokenManager, private val tokenManager: TokenManager,
private val settingsDataStore: com.garfiec.librechat.core.data.datastore.SettingsDataStore, private val settingsDataStore: com.garfiec.librechat.core.data.datastore.SettingsDataStore,
) : ViewModel() { ) : ViewModel() {
companion object {
private const val SEARCH_DEBOUNCE_MS = 300L
}
private val bannerStateHolder = BannerStateHolder(bannerRepository, viewModelScope) private val bannerStateHolder = BannerStateHolder(bannerRepository, viewModelScope)
private val versionCheckStateHolder = VersionCheckStateHolder(configRepository, settingsDataStore, viewModelScope) private val versionCheckStateHolder = VersionCheckStateHolder(configRepository, settingsDataStore, viewModelScope)
private val conversationListStateHolder = ConversationListStateHolder(conversationRepository, viewModelScope)
private val _isLoggedIn = MutableStateFlow(tokenManager.isAuthenticated) private val _isLoggedIn = MutableStateFlow(tokenManager.isAuthenticated)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow() val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
val versionMismatch: StateFlow<VersionMismatchState?> = versionCheckStateHolder.versionMismatch val versionMismatch: StateFlow<VersionMismatchState?> = versionCheckStateHolder.versionMismatch
private val _recentConversations = MutableStateFlow<List<Conversation>>(emptyList())
private val _groupedConversations = MutableStateFlow<List<Pair<String, List<Conversation>>>>(emptyList())
private val _activeConversationId = MutableStateFlow<String?>(null)
private val _searchQuery = MutableStateFlow("")
private val _favorites = MutableStateFlow<Set<String>>(emptySet()) private val _favorites = MutableStateFlow<Set<String>>(emptySet())
private val _favoriteConversations = MutableStateFlow<List<Conversation>>(emptyList()) private val _favoriteConversations = MutableStateFlow<List<Conversation>>(emptyList())
private var nextCursor: String? = null
private val _hasMore = MutableStateFlow(true)
private val _isLoadingMore = MutableStateFlow(false)
private val _isRefreshing = MutableStateFlow(false)
val banners: StateFlow<List<Banner>> = bannerStateHolder.banners val banners: StateFlow<List<Banner>> = bannerStateHolder.banners
val dismissedBannerIds: StateFlow<Set<String>> = bannerStateHolder.dismissedBannerIds val dismissedBannerIds: StateFlow<Set<String>> = bannerStateHolder.dismissedBannerIds
@ -125,15 +108,19 @@ class NavHostViewModel(
val drawerUiState: StateFlow<DrawerUiState> = combine( val drawerUiState: StateFlow<DrawerUiState> = combine(
combine( combine(
_groupedConversations, conversationListStateHolder.groupedConversations,
_activeConversationId, conversationListStateHolder.activeConversationId,
_favorites, _favorites,
_favoriteConversations, _favoriteConversations,
_searchQuery, conversationListStateHolder.searchQuery,
) { grouped, activeId, favIds, favConvos, query -> ) { grouped, activeId, favIds, favConvos, query ->
DrawerDataSnapshot(grouped, activeId, favIds, favConvos, query) DrawerDataSnapshot(grouped, activeId, favIds, favConvos, query)
}, },
combine(_isRefreshing, _isLoadingMore, _hasMore) { refreshing, loadingMore, hasMore -> combine(
conversationListStateHolder.isRefreshing,
conversationListStateHolder.isLoadingMore,
conversationListStateHolder.hasMore,
) { refreshing, loadingMore, hasMore ->
Triple(refreshing, loadingMore, hasMore) Triple(refreshing, loadingMore, hasMore)
}, },
) { data, (refreshing, loadingMore, hasMore) -> ) { data, (refreshing, loadingMore, hasMore) ->
@ -161,104 +148,40 @@ class NavHostViewModel(
Logger.w(e) { "Failed to check auth state on init" } Logger.w(e) { "Failed to check auth state on init" }
} }
} }
observeConversations()
observeSearchQuery()
observeBookmarks() observeBookmarks()
observeRecentConversationsForFavorites()
bannerStateHolder.fetchBanners() bannerStateHolder.fetchBanners()
loadInitialConversations()
} }
private fun observeConversations() { private fun observeRecentConversationsForFavorites() {
viewModelScope.launch { viewModelScope.launch {
try { conversationListStateHolder.recentConversations.collect {
conversationRepository.observeConversations().collect { result -> updateFavoriteConversations()
if (result is Result.Success) {
_recentConversations.value = result.data
if (_searchQuery.value.isBlank()) {
_groupedConversations.value = groupConversationsByDate(result.data)
}
updateFavoriteConversations()
}
}
} catch (e: Exception) {
Logger.w(e) { "Failed to observe conversations" }
} }
} }
} }
private fun loadInitialConversations() {
viewModelScope.launch {
loadPage(cursor = null)
}
}
private suspend fun loadPage(cursor: String?): Boolean {
return try {
val result = conversationRepository.loadNextPage(cursor = cursor)
if (result is Result.Success) {
nextCursor = result.data
_hasMore.value = result.data != null
true
} else {
Logger.w { "Failed to load conversations page" }
false
}
} catch (e: Exception) {
Logger.w(e) { "Failed to load conversations page" }
false
}
}
fun loadMoreConversations() { fun loadMoreConversations() {
if (_isLoadingMore.value || !_hasMore.value) return conversationListStateHolder.loadMoreConversations()
_isLoadingMore.value = true
viewModelScope.launch {
loadPage(cursor = nextCursor)
_isLoadingMore.value = false
}
} }
fun onAuthComplete() { fun onAuthComplete() {
_isLoggedIn.value = true _isLoggedIn.value = true
loadInitialConversations() conversationListStateHolder.refreshConversations()
bannerStateHolder.fetchBanners() bannerStateHolder.fetchBanners()
versionCheckStateHolder.checkBackendVersion() versionCheckStateHolder.checkBackendVersion()
} }
fun refreshConversations() { fun refreshConversations() {
_isRefreshing.value = true conversationListStateHolder.refreshConversations()
viewModelScope.launch {
nextCursor = null
_hasMore.value = true
loadPage(cursor = null)
_isRefreshing.value = false
}
} }
fun setActiveConversation(conversationId: String?) { fun setActiveConversation(conversationId: String?) {
_activeConversationId.value = conversationId conversationListStateHolder.setActiveConversation(conversationId)
} }
fun onSearchQueryChanged(query: String) { fun onSearchQueryChanged(query: String) {
_searchQuery.value = query conversationListStateHolder.onSearchQueryChanged(query)
if (query.isBlank()) {
_groupedConversations.value = groupConversationsByDate(_recentConversations.value)
}
}
@OptIn(FlowPreview::class)
private fun observeSearchQuery() {
viewModelScope.launch {
_searchQuery
.filter { it.isNotBlank() }
.debounce(SEARCH_DEBOUNCE_MS)
.collectLatest { query ->
val filtered = _recentConversations.value.filter { conversation ->
conversation.title?.contains(query, ignoreCase = true) == true
}
_groupedConversations.value = groupConversationsByDate(filtered)
}
}
} }
private fun observeBookmarks() { private fun observeBookmarks() {
@ -282,7 +205,7 @@ class NavHostViewModel(
private fun updateFavoriteConversations() { private fun updateFavoriteConversations() {
val bookmarkedIds = _favorites.value val bookmarkedIds = _favorites.value
val allConversations = _recentConversations.value val allConversations = conversationListStateHolder.recentConversations.value
_favoriteConversations.value = allConversations.filter { conversation -> _favoriteConversations.value = allConversations.filter { conversation ->
conversation.conversationId in bookmarkedIds || conversation.conversationId in bookmarkedIds ||
conversation.tags.any { conversation.tags.any {
@ -314,30 +237,13 @@ class NavHostViewModel(
viewModelScope.launch { viewModelScope.launch {
authRepository.logout() authRepository.logout()
_isLoggedIn.value = false _isLoggedIn.value = false
_recentConversations.value = emptyList() conversationListStateHolder.reset()
_groupedConversations.value = emptyList()
_activeConversationId.value = null
_searchQuery.value = ""
_favorites.value = emptySet() _favorites.value = emptySet()
_favoriteConversations.value = emptyList() _favoriteConversations.value = emptyList()
_sidebarMode.value = SidebarMode.Conversations _sidebarMode.value = SidebarMode.Conversations
_selectedSettingsCategory.value = null _selectedSettingsCategory.value = null
} }
} }
private fun groupConversationsByDate(
conversations: List<Conversation>,
): List<Pair<String, List<Conversation>>> {
if (conversations.isEmpty()) return emptyList()
return conversations
.groupBy { conversation ->
conversation.updatedAt
?.toInstantOrNull()
?.toRelativeDateGroup()
?: "Unknown"
}
.toList()
}
} }
private data class DrawerDataSnapshot( private data class DrawerDataSnapshot(