Merge pull request #19 from garfiec/refactor/nav-host-viewmodel_stage1

refactor: NavHostViewModel stage 1 — low-hanging fruit cleanup
This commit is contained in:
Garfie Chiu 2026-04-06 23:26:42 -05:00 committed by GitHub
commit 6f86a2b00a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -14,16 +14,19 @@ import com.garfiec.librechat.core.model.Banner
import com.garfiec.librechat.core.model.Conversation import com.garfiec.librechat.core.model.Conversation
import com.garfiec.librechat.core.model.EModelEndpoint import com.garfiec.librechat.core.model.EModelEndpoint
import com.garfiec.librechat.core.network.client.TokenManager import com.garfiec.librechat.core.network.client.TokenManager
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow 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.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
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
@ -79,6 +82,10 @@ class NavHostViewModel(
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 _isLoggedIn = MutableStateFlow(tokenManager.isAuthenticated) private val _isLoggedIn = MutableStateFlow(tokenManager.isAuthenticated)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow() val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
@ -128,8 +135,6 @@ class NavHostViewModel(
_selectedSettingsCategory.value = category _selectedSettingsCategory.value = category
} }
private var searchJob: Job? = null
val drawerUiState: StateFlow<DrawerUiState> = combine( val drawerUiState: StateFlow<DrawerUiState> = combine(
combine( combine(
_groupedConversations, _groupedConversations,
@ -169,6 +174,7 @@ class NavHostViewModel(
} }
} }
observeConversations() observeConversations()
observeSearchQuery()
observeBookmarks() observeBookmarks()
fetchBanners() fetchBanners()
loadInitialConversations() loadInitialConversations()
@ -194,15 +200,24 @@ class NavHostViewModel(
private fun loadInitialConversations() { private fun loadInitialConversations() {
viewModelScope.launch { viewModelScope.launch {
try { loadPage(cursor = null)
val result = conversationRepository.loadNextPage(cursor = null) }
if (result is Result.Success) { }
nextCursor = result.data
_hasMore.value = result.data != null private suspend fun loadPage(cursor: String?): Boolean {
} return try {
} catch (e: Exception) { val result = conversationRepository.loadNextPage(cursor = cursor)
Logger.w(e) { "Failed to load initial conversations" } 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
} }
} }
@ -210,19 +225,8 @@ class NavHostViewModel(
if (_isLoadingMore.value || !_hasMore.value) return if (_isLoadingMore.value || !_hasMore.value) return
_isLoadingMore.value = true _isLoadingMore.value = true
viewModelScope.launch { viewModelScope.launch {
try { loadPage(cursor = nextCursor)
val result = conversationRepository.loadNextPage(cursor = nextCursor) _isLoadingMore.value = false
if (result is Result.Success) {
nextCursor = result.data
_hasMore.value = result.data != null
} else {
Logger.w { "Failed to load more conversations" }
}
} catch (e: Exception) {
Logger.w(e) { "Failed to load more conversations" }
} finally {
_isLoadingMore.value = false
}
} }
} }
@ -236,19 +240,10 @@ class NavHostViewModel(
fun refreshConversations() { fun refreshConversations() {
_isRefreshing.value = true _isRefreshing.value = true
viewModelScope.launch { viewModelScope.launch {
try { nextCursor = null
nextCursor = null _hasMore.value = true
_hasMore.value = true loadPage(cursor = null)
val result = conversationRepository.loadNextPage(cursor = null) _isRefreshing.value = false
if (result is Result.Success) {
nextCursor = result.data
_hasMore.value = result.data != null
}
} catch (e: Exception) {
Logger.w(e) { "Failed to refresh conversations" }
} finally {
_isRefreshing.value = false
}
} }
} }
@ -258,19 +253,23 @@ class NavHostViewModel(
fun onSearchQueryChanged(query: String) { fun onSearchQueryChanged(query: String) {
_searchQuery.value = query _searchQuery.value = query
searchJob?.cancel()
if (query.isBlank()) { if (query.isBlank()) {
_groupedConversations.value = groupConversationsByDate(_recentConversations.value) _groupedConversations.value = groupConversationsByDate(_recentConversations.value)
return
} }
}
searchJob = viewModelScope.launch { @OptIn(FlowPreview::class)
delay(300) private fun observeSearchQuery() {
val filtered = _recentConversations.value.filter { conversation -> viewModelScope.launch {
conversation.title?.contains(query, ignoreCase = true) == true _searchQuery
} .filter { it.isNotBlank() }
_groupedConversations.value = groupConversationsByDate(filtered) .debounce(SEARCH_DEBOUNCE_MS)
.collectLatest { query ->
val filtered = _recentConversations.value.filter { conversation ->
conversation.title?.contains(query, ignoreCase = true) == true
}
_groupedConversations.value = groupConversationsByDate(filtered)
}
} }
} }
@ -312,8 +311,16 @@ class NavHostViewModel(
if (result is Result.Success) { if (result is Result.Success) {
val now = Clock.System.now() val now = Clock.System.now()
_banners.value = result.data.filter { banner -> _banners.value = result.data.filter { banner ->
val from = banner.displayFrom?.let { runCatching { Instant.parse(it) }.getOrNull() } val from = banner.displayFrom?.let {
val to = banner.displayTo?.let { runCatching { Instant.parse(it) }.getOrNull() } runCatching { Instant.parse(it) }
.onFailure { e -> Logger.w(e) { "Failed to parse banner displayFrom: $it" } }
.getOrNull()
}
val to = banner.displayTo?.let {
runCatching { Instant.parse(it) }
.onFailure { e -> Logger.w(e) { "Failed to parse banner displayTo: $it" } }
.getOrNull()
}
val afterStart = from == null || now >= from val afterStart = from == null || now >= from
val beforeEnd = to == null || now < to val beforeEnd = to == null || now < to
afterStart && beforeEnd afterStart && beforeEnd
@ -332,24 +339,29 @@ class NavHostViewModel(
} }
fun dismissBanner(bannerId: String) { fun dismissBanner(bannerId: String) {
_dismissedBannerIds.value = _dismissedBannerIds.value + bannerId _dismissedBannerIds.update { it + bannerId }
} }
private fun checkBackendVersion() { private fun checkBackendVersion() {
viewModelScope.launch { viewModelScope.launch {
val result = configRepository.checkBackendVersion() when (val result = configRepository.checkBackendVersion()) {
if (result is Result.Success) { is Result.Success -> {
val checkResult = result.data val checkResult = result.data
val detectedVersion = checkResult.backendVersion val detectedVersion = checkResult.backendVersion
if (!checkResult.isCompatible && detectedVersion != null) { if (!checkResult.isCompatible && detectedVersion != null) {
val dismissedVersion = settingsDataStore.dismissedVersionWarning.first() val dismissedVersion = settingsDataStore.dismissedVersionWarning.first()
if (dismissedVersion != detectedVersion) { if (dismissedVersion != detectedVersion) {
_versionMismatch.value = VersionMismatchState( _versionMismatch.value = VersionMismatchState(
supportedVersion = checkResult.supportedVersion, supportedVersion = checkResult.supportedVersion,
backendVersion = detectedVersion, backendVersion = detectedVersion,
) )
}
} }
} }
is Result.Error -> {
Logger.w(result.exception) { "Failed to check backend version: ${result.message}" }
}
else -> {}
} }
} }
} }