Merge pull request #35 from garfiec/feat/favorites-server-backed-tags

feat(favorites): move bookmarks to server-backed tags with offline cache
This commit is contained in:
Garfie Chiu 2026-04-13 20:17:04 -05:00 committed by GitHub
commit 9e06c43096
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 782 additions and 341 deletions

View file

@ -52,11 +52,6 @@ Conversations are integrated into the drawer body. Agents, Files, and Settings a
This module depends on `:shared`, all `:core:*`, and all `:feature:*` modules. This module depends on `:shared`, all `:core:*`, and all `:feature:*` modules.
It applies convention plugins: `librechat.mobile.application`, `librechat.mobile.compose`, `librechat.mobile.koin`. It applies convention plugins: `librechat.mobile.application`, `librechat.mobile.compose`, `librechat.mobile.koin`.
### Server-Synced Favorites
- `NavHostViewModel` (shared) exposes `favorites: StateFlow<Set<String>>` and `toggleFavorite()`
- Currently backed by `SettingsDataStore` (local) — server sync via `UserApi.getFavorites()/updateFavorites()` available but needs wiring
- `DrawerContent` (shared) shows star icon per conversation (filled = bookmarked)
### Server Banners ### Server Banners
- `NavHostViewModel` (shared) fetches banners from `BannerRepository` on init, filters expired via `displayFrom`/`displayTo` with `Instant.parse()` - `NavHostViewModel` (shared) fetches banners from `BannerRepository` on init, filters expired via `displayFrom`/`displayTo` with `Instant.parse()`
- Dismissed banner IDs tracked in-memory via `dismissedBannerIds: StateFlow<Set<String>>` (session-scoped, not persisted) - Dismissed banner IDs tracked in-memory via `dismissedBannerIds: StateFlow<Set<String>>` (session-scoped, not persisted)

View file

@ -173,24 +173,6 @@ class DataStoreRoundTripTest {
assertThat(store.ttsPitch.first()).isEqualTo(0.8f) assertThat(store.ttsPitch.first()).isEqualTo(0.8f)
} }
@Test
fun settingsDataStore_roundTrip_bookmarks() = runTest(testDispatcher) {
val ds = createDataStore("settings-bookmarks")
val store = SettingsDataStore(ds)
assertThat(store.bookmarkedConversationIds.first()).isEmpty()
store.toggleBookmark("conv-001")
assertThat(store.bookmarkedConversationIds.first()).containsExactly("conv-001")
store.toggleBookmark("conv-002")
assertThat(store.bookmarkedConversationIds.first()).containsExactly("conv-001", "conv-002")
// Toggle off
store.toggleBookmark("conv-001")
assertThat(store.bookmarkedConversationIds.first()).containsExactly("conv-002")
}
@Test @Test
fun settingsDataStore_roundTrip_mcpServers() = runTest(testDispatcher) { fun settingsDataStore_roundTrip_mcpServers() = runTest(testDispatcher) {
val ds = createDataStore("settings-mcp") val ds = createDataStore("settings-mcp")

View file

@ -0,0 +1,204 @@
package com.garfiec.librechat.core.data.repository
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.data.db.dao.ConversationDao
import com.garfiec.librechat.core.data.db.entity.ConversationEntity
import com.garfiec.librechat.core.model.Conversation
import com.garfiec.librechat.core.model.SAVED_TAG
import com.garfiec.librechat.core.model.response.ConversationListResponse
import com.garfiec.librechat.core.network.api.ConversationsApi
import com.google.common.truth.Truth.assertThat
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.just
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ConversationRepositoryImplTest {
private val conversationsApi = mockk<ConversationsApi>(relaxed = true)
private val conversationDao = mockk<ConversationDao>(relaxed = true)
private val json = Json { ignoreUnknownKeys = true }
private lateinit var repository: ConversationRepositoryImpl
@Before
fun setup() {
repository = ConversationRepositoryImpl(
conversationsApi = conversationsApi,
conversationDao = conversationDao,
json = json,
)
}
private fun entity(id: String, tagsJson: String) = ConversationEntity(
conversationId = id,
title = "Title $id",
user = "user-1",
endpoint = null,
endpointType = null,
model = null,
agentId = null,
isArchived = false,
tags = tagsJson,
iconURL = null,
greeting = null,
modelParams = null,
createdAt = 0L,
updatedAt = 0L,
lastSyncedAt = 0L,
)
@Test
fun `loadNextPage delegates server entities to upsertPreservingTags`() = runTest {
val serverConvo = Conversation(conversationId = "convo-1", title = "Server Title")
coEvery { conversationsApi.getConversations(any(), any(), any(), any(), any(), any(), any()) } returns
ConversationListResponse(conversations = listOf(serverConvo), nextCursor = null)
val captured = slot<List<ConversationEntity>>()
coEvery { conversationDao.upsertPreservingTags(capture(captured)) } answers {}
val result = repository.loadNextPage(cursor = null)
assertThat(result).isInstanceOf(Result.Success::class.java)
val entity = captured.captured.single()
assertThat(entity.conversationId).isEqualTo("convo-1")
assertThat(entity.title).isEqualTo("Server Title")
}
@Test
fun `saveConversation delegates to upsertPreservingTags`() = runTest {
val streamingConvo = Conversation(conversationId = "convo-1", title = "Updated mid-stream")
val captured = slot<ConversationEntity>()
coEvery { conversationDao.upsertPreservingTags(capture(captured)) } answers {}
repository.saveConversation(streamingConvo)
assertThat(captured.captured.conversationId).isEqualTo("convo-1")
assertThat(captured.captured.title).isEqualTo("Updated mid-stream")
}
@Test
fun `saveConversation no-op for blank conversationId`() = runTest {
val blank = Conversation(conversationId = "", title = "Blank")
repository.saveConversation(blank)
coVerify(exactly = 0) { conversationDao.upsertPreservingTags(any<ConversationEntity>()) }
}
@Test
fun `syncFavoritesFromServer adds SAVED_TAG to existing rows server reports as favorited`() = runTest {
val serverConvo = Conversation(conversationId = "convo-1", title = "Cross-client fav")
coEvery {
conversationsApi.getConversations(any(), any(), any(), any(), any(), any(), any())
} returns ConversationListResponse(conversations = listOf(serverConvo), nextCursor = null)
coEvery { conversationDao.getById("convo-1") } returns entity("convo-1", """["work"]""")
coEvery { conversationDao.getAllConversations(false) } returns flowOf(emptyList())
coEvery { conversationDao.updateTags(any(), any(), any()) } just Runs
repository.syncFavoritesFromServer()
val tagsCaptor = slot<String>()
coVerify(exactly = 1) {
conversationDao.updateTags("convo-1", capture(tagsCaptor), any())
}
assertThat(json.decodeFromString<List<String>>(tagsCaptor.captured))
.containsExactly("work", SAVED_TAG).inOrder()
}
@Test
fun `syncFavoritesFromServer removes SAVED_TAG from rows server no longer considers favorited`() = runTest {
coEvery {
conversationsApi.getConversations(any(), any(), any(), any(), any(), any(), any())
} returns ConversationListResponse(conversations = emptyList(), nextCursor = null)
val staleFav = entity("convo-stale", """["work","Saved"]""")
coEvery { conversationDao.getAllConversations(false) } returns flowOf(listOf(staleFav))
coEvery { conversationDao.updateTags(any(), any(), any()) } just Runs
repository.syncFavoritesFromServer()
val tagsCaptor = slot<String>()
coVerify(exactly = 1) {
conversationDao.updateTags("convo-stale", capture(tagsCaptor), any())
}
assertThat(json.decodeFromString<List<String>>(tagsCaptor.captured))
.containsExactly("work")
}
@Test
fun `syncFavoritesFromServer upserts missing conversations with SAVED_TAG preset`() = runTest {
val serverConvo = Conversation(conversationId = "convo-new", title = "From web")
coEvery {
conversationsApi.getConversations(any(), any(), any(), any(), any(), any(), any())
} returns ConversationListResponse(conversations = listOf(serverConvo), nextCursor = null)
coEvery { conversationDao.getById("convo-new") } returns null
coEvery { conversationDao.getAllConversations(false) } returns flowOf(emptyList())
val upsertCaptor = slot<ConversationEntity>()
coEvery { conversationDao.upsert(capture(upsertCaptor)) } answers {}
repository.syncFavoritesFromServer()
assertThat(upsertCaptor.captured.conversationId).isEqualTo("convo-new")
assertThat(json.decodeFromString<List<String>>(upsertCaptor.captured.tags))
.containsExactly(SAVED_TAG)
}
@Test
fun `syncFavoritesFromServer paginates through multiple pages`() = runTest {
val page1 = ConversationListResponse(
conversations = listOf(Conversation(conversationId = "c1")),
nextCursor = "cursor-2",
)
val page2 = ConversationListResponse(
conversations = listOf(Conversation(conversationId = "c2")),
nextCursor = null,
)
coEvery {
conversationsApi.getConversations(null, any(), any(), any(), any(), any(), any())
} returns page1
coEvery {
conversationsApi.getConversations("cursor-2", any(), any(), any(), any(), any(), any())
} returns page2
coEvery { conversationDao.getById(any()) } returns null
coEvery { conversationDao.getAllConversations(false) } returns flowOf(emptyList())
coEvery { conversationDao.upsert(any()) } answers {}
repository.syncFavoritesFromServer()
coVerify(exactly = 1) {
conversationsApi.getConversations(null, any(), any(), any(), any(), any(), any())
}
coVerify(exactly = 1) {
conversationsApi.getConversations("cursor-2", any(), any(), any(), any(), any(), any())
}
coVerify(exactly = 2) { conversationDao.upsert(any()) }
}
@Test
fun `syncFavoritesFromServer no-op when local and server match`() = runTest {
val serverConvo = Conversation(conversationId = "convo-1")
coEvery {
conversationsApi.getConversations(any(), any(), any(), any(), any(), any(), any())
} returns ConversationListResponse(conversations = listOf(serverConvo), nextCursor = null)
coEvery { conversationDao.getById("convo-1") } returns entity("convo-1", """["Saved"]""")
coEvery {
conversationDao.getAllConversations(false)
} returns flowOf(listOf(entity("convo-1", """["Saved"]""")))
repository.syncFavoritesFromServer()
coVerify(exactly = 0) { conversationDao.updateTags(any(), any(), any()) }
coVerify(exactly = 0) { conversationDao.upsert(any()) }
}
}

View file

@ -0,0 +1,188 @@
package com.garfiec.librechat.core.data.repository
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.data.db.dao.ConversationTagDao
import com.garfiec.librechat.core.data.db.entity.ConversationTagEntity
import com.garfiec.librechat.core.model.ConversationTag
import com.garfiec.librechat.core.model.SAVED_TAG
import com.garfiec.librechat.core.network.api.TagsApi
import com.google.common.truth.Truth.assertThat
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class TagRepositoryImplTest {
private val tagsApi = mockk<TagsApi>(relaxed = true)
private val tagDao = mockk<ConversationTagDao>(relaxed = true)
private val conversationRepository = mockk<ConversationRepository>(relaxed = true)
private lateinit var repository: TagRepositoryImpl
private val serverTags = listOf(
ConversationTag(tag = "work", count = 3, position = 0),
ConversationTag(tag = "personal", count = 2, position = 1),
ConversationTag(tag = SAVED_TAG, count = 5, position = 2),
)
@Before
fun setup() {
repository = TagRepositoryImpl(
tagsApi = tagsApi,
tagDao = tagDao,
conversationRepository = conversationRepository,
)
}
@Test
fun `refreshTags fetches from api and replaces all tags in dao`() = runTest {
coEvery { tagsApi.getTags() } returns serverTags
coEvery { tagDao.replaceAll(any()) } just Runs
val result = repository.refreshTags()
assertThat(result).isInstanceOf(Result.Success::class.java)
coVerify(exactly = 1) { tagsApi.getTags() }
coVerify(exactly = 1) {
tagDao.replaceAll(
match { entities ->
entities.size == 3 &&
entities.map { it.tag }.containsAll(listOf("work", "personal", SAVED_TAG))
},
)
}
}
@Test
fun `refreshTags returns Error when api throws`() = runTest {
coEvery { tagsApi.getTags() } throws RuntimeException("network down")
val result = repository.refreshTags()
assertThat(result).isInstanceOf(Result.Error::class.java)
coVerify(exactly = 0) { tagDao.replaceAll(any()) }
}
@Test
fun `toggleFavorite adds SAVED_TAG when not present and preserves existing tags`() = runTest {
coEvery { tagsApi.updateConversationTags(any(), any()) } just Runs
coEvery { conversationRepository.updateConversationTagsLocal(any(), any()) } just Runs
val result = repository.toggleFavorite(
conversationId = "convo-1",
currentTags = listOf("work"),
)
assertThat(result).isInstanceOf(Result.Success::class.java)
coVerify(exactly = 1) {
tagsApi.updateConversationTags("convo-1", listOf("work", SAVED_TAG))
}
coVerify(exactly = 1) {
conversationRepository.updateConversationTagsLocal("convo-1", listOf("work", SAVED_TAG))
}
}
@Test
fun `toggleFavorite removes SAVED_TAG when already present`() = runTest {
coEvery { tagsApi.updateConversationTags(any(), any()) } just Runs
coEvery { conversationRepository.updateConversationTagsLocal(any(), any()) } just Runs
val result = repository.toggleFavorite(
conversationId = "convo-1",
currentTags = listOf("work", SAVED_TAG),
)
assertThat(result).isInstanceOf(Result.Success::class.java)
coVerify(exactly = 1) {
tagsApi.updateConversationTags("convo-1", listOf("work"))
}
coVerify(exactly = 1) {
conversationRepository.updateConversationTagsLocal("convo-1", listOf("work"))
}
}
@Test
fun `toggleFavorite removes SAVED_TAG when it is the only tag`() = runTest {
coEvery { tagsApi.updateConversationTags(any(), any()) } just Runs
coEvery { conversationRepository.updateConversationTagsLocal(any(), any()) } just Runs
val result = repository.toggleFavorite(
conversationId = "convo-1",
currentTags = listOf(SAVED_TAG),
)
assertThat(result).isInstanceOf(Result.Success::class.java)
coVerify(exactly = 1) {
tagsApi.updateConversationTags("convo-1", emptyList())
}
}
@Test
fun `toggleFavorite returns Error when api throws and skips local update`() = runTest {
coEvery {
tagsApi.updateConversationTags(any(), any())
} throws RuntimeException("boom")
val result = repository.toggleFavorite(
conversationId = "convo-1",
currentTags = emptyList(),
)
assertThat(result).isInstanceOf(Result.Error::class.java)
coVerify(exactly = 0) {
conversationRepository.updateConversationTagsLocal(any(), any())
}
}
@Test
fun `observeTags maps dao entities to domain models sorted by position`() = runTest {
val entities = listOf(
ConversationTagEntity(
id = 1,
tag = "work",
user = "user-1",
description = null,
count = 3,
position = 0,
createdAt = 0L,
updatedAt = 0L,
),
ConversationTagEntity(
id = 2,
tag = "personal",
user = "user-1",
description = null,
count = 2,
position = 1,
createdAt = 0L,
updatedAt = 0L,
),
)
every { tagDao.getAllTags() } returns flowOf(entities)
val result = repository.observeTags().first()
assertThat(result).hasSize(2)
assertThat(result.map { it.tag }).containsExactly("work", "personal").inOrder()
assertThat(result.map { it.count }).containsExactly(3, 2).inOrder()
}
@Test
fun `clearCache delegates to tagDao deleteAll`() = runTest {
coEvery { tagDao.deleteAll() } just Runs
repository.clearCache()
coVerify(exactly = 1) { tagDao.deleteAll() }
}
}

View file

@ -6,7 +6,6 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -69,10 +68,6 @@ class SettingsDataStore(
prefs[KEY_TTS_VOICE_NAME] ?: "" prefs[KEY_TTS_VOICE_NAME] ?: ""
} }
val bookmarkedConversationIds: Flow<Set<String>> = dataStore.data.map { prefs ->
prefs[KEY_BOOKMARKED_CONVERSATIONS] ?: emptySet()
}
val tabletSidebarOpen: Flow<Boolean> = dataStore.data.map { prefs -> val tabletSidebarOpen: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[KEY_TABLET_SIDEBAR_OPEN] ?: true prefs[KEY_TABLET_SIDEBAR_OPEN] ?: true
} }
@ -308,17 +303,6 @@ class SettingsDataStore(
} }
} }
suspend fun toggleBookmark(conversationId: String) {
dataStore.edit { prefs ->
val current = prefs[KEY_BOOKMARKED_CONVERSATIONS] ?: emptySet()
prefs[KEY_BOOKMARKED_CONVERSATIONS] = if (conversationId in current) {
current - conversationId
} else {
current + conversationId
}
}
}
companion object { companion object {
private val KEY_LATEX_RENDERER = stringPreferencesKey("latex_renderer") private val KEY_LATEX_RENDERER = stringPreferencesKey("latex_renderer")
private val KEY_CHAT_FONT_SIZE = stringPreferencesKey("chat_font_size") private val KEY_CHAT_FONT_SIZE = stringPreferencesKey("chat_font_size")
@ -334,7 +318,6 @@ class SettingsDataStore(
private val KEY_TTS_SPEECH_RATE = floatPreferencesKey("tts_speech_rate") private val KEY_TTS_SPEECH_RATE = floatPreferencesKey("tts_speech_rate")
private val KEY_TTS_PITCH = floatPreferencesKey("tts_pitch") private val KEY_TTS_PITCH = floatPreferencesKey("tts_pitch")
private val KEY_TTS_VOICE_NAME = stringPreferencesKey("tts_voice_name") private val KEY_TTS_VOICE_NAME = stringPreferencesKey("tts_voice_name")
private val KEY_BOOKMARKED_CONVERSATIONS = stringSetPreferencesKey("bookmarked_conversations")
private val KEY_TABLET_SIDEBAR_OPEN = booleanPreferencesKey("tablet_sidebar_open") private val KEY_TABLET_SIDEBAR_OPEN = booleanPreferencesKey("tablet_sidebar_open")
private val KEY_TABLET_SIDEBAR_GESTURE_ENABLED = booleanPreferencesKey("tablet_sidebar_gesture_enabled") private val KEY_TABLET_SIDEBAR_GESTURE_ENABLED = booleanPreferencesKey("tablet_sidebar_gesture_enabled")
private val KEY_AUTO_SEND_AFTER_STT = booleanPreferencesKey("auto_send_after_stt") private val KEY_AUTO_SEND_AFTER_STT = booleanPreferencesKey("auto_send_after_stt")

View file

@ -2,6 +2,7 @@ package com.garfiec.librechat.core.data.db.dao
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert import androidx.room.Upsert
import com.garfiec.librechat.core.data.db.entity.ConversationEntity import com.garfiec.librechat.core.data.db.entity.ConversationEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -26,6 +27,28 @@ interface ConversationDao {
@Upsert @Upsert
suspend fun upsertAll(conversations: List<ConversationEntity>) suspend fun upsertAll(conversations: List<ConversationEntity>)
// Atomic read-merge-write that preserves locally-stored tags when a row
// already exists. Used by paths that receive server responses without the
// `tags` field (list endpoint + streaming save). Must run inside a single
// transaction so it can't be interleaved by a concurrent updateTags write
// from `syncFavoritesFromServer` (which would otherwise get clobbered).
@Transaction
suspend fun upsertPreservingTags(entities: List<ConversationEntity>) {
for (entity in entities) {
if (entity.conversationId.isBlank()) {
upsert(entity)
continue
}
val existing = getById(entity.conversationId)
val toUpsert = if (existing != null) entity.copy(tags = existing.tags) else entity
upsert(toUpsert)
}
}
suspend fun upsertPreservingTags(entity: ConversationEntity) {
upsertPreservingTags(listOf(entity))
}
@Query("DELETE FROM conversations WHERE conversationId = :id") @Query("DELETE FROM conversations WHERE conversationId = :id")
suspend fun deleteById(id: String) suspend fun deleteById(id: String)
@ -38,6 +61,9 @@ interface ConversationDao {
@Query("UPDATE conversations SET isArchived = :isArchived, updatedAt = :updatedAt WHERE conversationId = :id") @Query("UPDATE conversations SET isArchived = :isArchived, updatedAt = :updatedAt WHERE conversationId = :id")
suspend fun updateArchived(id: String, isArchived: Boolean, updatedAt: Long) suspend fun updateArchived(id: String, isArchived: Boolean, updatedAt: Long)
@Query("UPDATE conversations SET tags = :tagsJson, updatedAt = :updatedAt WHERE conversationId = :id")
suspend fun updateTags(id: String, tagsJson: String, updatedAt: Long)
@Query("DELETE FROM conversations") @Query("DELETE FROM conversations")
suspend fun deleteAll() suspend fun deleteAll()
} }

View file

@ -2,21 +2,25 @@ package com.garfiec.librechat.core.data.db.dao
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert import androidx.room.Upsert
import com.garfiec.librechat.core.data.db.entity.ConversationTagEntity import com.garfiec.librechat.core.data.db.entity.ConversationTagEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface ConversationTagDao { interface ConversationTagDao {
@Query("SELECT * FROM conversation_tags WHERE user = :userId ORDER BY position ASC") @Query("SELECT * FROM conversation_tags ORDER BY position ASC")
fun getTagsForUser(userId: String): Flow<List<ConversationTagEntity>> fun getAllTags(): Flow<List<ConversationTagEntity>>
@Upsert
suspend fun upsert(tag: ConversationTagEntity)
@Upsert @Upsert
suspend fun upsertAll(tags: List<ConversationTagEntity>) suspend fun upsertAll(tags: List<ConversationTagEntity>)
@Query("DELETE FROM conversation_tags WHERE id = :id") @Query("DELETE FROM conversation_tags")
suspend fun deleteById(id: Long) suspend fun deleteAll()
@Transaction
suspend fun replaceAll(tags: List<ConversationTagEntity>) {
deleteAll()
upsertAll(tags)
}
} }

View file

@ -0,0 +1,42 @@
package com.garfiec.librechat.core.data.mapper
import com.garfiec.librechat.core.data.db.entity.ConversationTagEntity
import com.garfiec.librechat.core.model.ConversationTag
import kotlin.time.Clock
import kotlin.time.Instant
fun ConversationTag.toEntity(): ConversationTagEntity = ConversationTagEntity(
tag = tag ?: "",
user = user ?: "",
description = description,
count = count,
position = position,
createdAt = parseTagTimestamp(createdAt),
updatedAt = parseTagTimestamp(updatedAt),
)
fun ConversationTagEntity.toModel(): ConversationTag = ConversationTag(
id = null,
tag = tag,
user = user,
description = description,
count = count,
position = position,
createdAt = formatTagTimestamp(createdAt),
updatedAt = formatTagTimestamp(updatedAt),
)
fun List<ConversationTagEntity>.toModels(): List<ConversationTag> = map { it.toModel() }
private fun parseTagTimestamp(dateString: String?): Long {
if (dateString == null) return Clock.System.now().toEpochMilliseconds()
return try {
Instant.parse(dateString).toEpochMilliseconds()
} catch (_: Exception) {
Clock.System.now().toEpochMilliseconds()
}
}
private fun formatTagTimestamp(epochMillis: Long): String {
return Instant.fromEpochMilliseconds(epochMillis).toString()
}

View file

@ -30,4 +30,6 @@ interface ConversationRepository {
suspend fun importConversation(jsonContent: String): Result<Conversation> suspend fun importConversation(jsonContent: String): Result<Conversation>
suspend fun deleteAll(): Result<Unit> suspend fun deleteAll(): Result<Unit>
suspend fun saveConversation(conversation: Conversation) suspend fun saveConversation(conversation: Conversation)
suspend fun updateConversationTagsLocal(id: String, tags: List<String>)
suspend fun syncFavoritesFromServer(): Result<Unit>
} }

View file

@ -1,6 +1,5 @@
package com.garfiec.librechat.core.data.repository package com.garfiec.librechat.core.data.repository
import co.touchlab.kermit.Logger
import com.garfiec.librechat.core.common.result.Result import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.common.result.safeApiCall import com.garfiec.librechat.core.common.result.safeApiCall
import com.garfiec.librechat.core.data.db.dao.ConversationDao import com.garfiec.librechat.core.data.db.dao.ConversationDao
@ -8,15 +7,21 @@ import com.garfiec.librechat.core.data.mapper.toEntity
import com.garfiec.librechat.core.data.mapper.toModel import com.garfiec.librechat.core.data.mapper.toModel
import com.garfiec.librechat.core.data.mapper.toModels import com.garfiec.librechat.core.data.mapper.toModels
import com.garfiec.librechat.core.model.Conversation import com.garfiec.librechat.core.model.Conversation
import com.garfiec.librechat.core.model.SAVED_TAG
import com.garfiec.librechat.core.model.request.ForkConversationRequest import com.garfiec.librechat.core.model.request.ForkConversationRequest
import com.garfiec.librechat.core.network.api.ConversationsApi import com.garfiec.librechat.core.network.api.ConversationsApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import kotlin.time.Clock import kotlin.time.Clock
class ConversationRepositoryImpl( class ConversationRepositoryImpl(
private val conversationsApi: ConversationsApi, private val conversationsApi: ConversationsApi,
private val conversationDao: ConversationDao, private val conversationDao: ConversationDao,
private val json: Json,
) : ConversationRepository { ) : ConversationRepository {
override fun observeConversations(isArchived: Boolean): Flow<Result<List<Conversation>>> { override fun observeConversations(isArchived: Boolean): Flow<Result<List<Conversation>>> {
@ -44,19 +49,15 @@ class ConversationRepositoryImpl(
sortBy = sortBy, sortBy = sortBy,
sortDirection = sortDirection, sortDirection = sortDirection,
) )
// Cache conversations locally conversationDao.upsertPreservingTags(response.conversations.map { it.toEntity() })
val entities = response.conversations.map { it.toEntity() }
conversationDao.upsertAll(entities)
response.nextCursor response.nextCursor
} }
} }
override suspend fun getConversation(id: String): Result<Conversation> { override suspend fun getConversation(id: String): Result<Conversation> {
// Try local cache first
val cached = conversationDao.getById(id) val cached = conversationDao.getById(id)
if (cached != null) return Result.Success(cached.toModel()) if (cached != null) return Result.Success(cached.toModel())
// Fetch from network
return safeApiCall { return safeApiCall {
val conversation = conversationsApi.getConversation(id) val conversation = conversationsApi.getConversation(id)
conversationDao.upsert(conversation.toEntity()) conversationDao.upsert(conversation.toEntity())
@ -75,7 +76,6 @@ class ConversationRepositoryImpl(
override suspend fun generateTitle(conversationId: String): Result<String> { override suspend fun generateTitle(conversationId: String): Result<String> {
return safeApiCall { return safeApiCall {
val response = conversationsApi.generateTitle(conversationId) val response = conversationsApi.generateTitle(conversationId)
// Update the local cache with the generated title
conversationDao.updateTitle(conversationId, response.title, Clock.System.now().toEpochMilliseconds()) conversationDao.updateTitle(conversationId, response.title, Clock.System.now().toEpochMilliseconds())
response.title response.title
} }
@ -153,16 +153,59 @@ class ConversationRepositoryImpl(
override suspend fun saveConversation(conversation: Conversation) { override suspend fun saveConversation(conversation: Conversation) {
val id = conversation.conversationId ?: return val id = conversation.conversationId ?: return
if (id.isBlank()) return if (id.isBlank()) return
conversationDao.upsert(conversation.toEntity()) conversationDao.upsertPreservingTags(conversation.toEntity())
} }
suspend fun refreshConversations() { override suspend fun updateConversationTagsLocal(id: String, tags: List<String>) {
try { conversationDao.updateTags(id, encodeTags(tags), Clock.System.now().toEpochMilliseconds())
val response = conversationsApi.getConversations() }
val entities = response.conversations.map { it.toEntity() }
conversationDao.upsertAll(entities) // Reconciles SAVED_TAG attachment between the local Room cache and server by
} catch (e: Exception) { // paginating `GET /api/convos?tags=Saved`. Needed because upstream's
Logger.w(e) { "Failed to refresh conversations" } // getConvosByCursor projection omits `tags`, so the main conversation list
// endpoint can't deliver cross-client favorite changes. Only the reserved
// SAVED_TAG is synced; other user-created tags aren't fetched here. Known
// gap: the stale-removal pass below only scans non-archived rows, so a
// conversation that was archived while favorited and later unfavorited
// elsewhere will keep its local SAVED_TAG until the user unarchives it.
override suspend fun syncFavoritesFromServer(): Result<Unit> = safeApiCall {
val serverFavoriteIds = mutableSetOf<String>()
var cursor: String? = null
do {
val response = conversationsApi.getConversations(
cursor = cursor,
tags = listOf(SAVED_TAG),
)
for (convo in response.conversations) {
val id = convo.conversationId ?: continue
serverFavoriteIds.add(id)
val existing = conversationDao.getById(id)
if (existing == null) {
conversationDao.upsert(
convo.toEntity().copy(tags = encodeTags(listOf(SAVED_TAG))),
)
} else {
val currentTags = existing.toModel().tags
if (SAVED_TAG !in currentTags) {
updateConversationTagsLocal(id, currentTags + SAVED_TAG)
}
}
}
cursor = response.nextCursor
} while (cursor != null)
val localEntities = conversationDao.getAllConversations(isArchived = false).first()
for (entity in localEntities) {
val currentTags = entity.toModel().tags
if (SAVED_TAG in currentTags && entity.conversationId !in serverFavoriteIds) {
updateConversationTagsLocal(
entity.conversationId,
currentTags.filterNot { it == SAVED_TAG },
)
}
} }
} }
private fun encodeTags(tags: List<String>): String =
json.encodeToString(ListSerializer(serializer<String>()), tags)
} }

View file

@ -2,8 +2,12 @@ package com.garfiec.librechat.core.data.repository
import com.garfiec.librechat.core.common.result.Result import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.model.ConversationTag import com.garfiec.librechat.core.model.ConversationTag
import kotlinx.coroutines.flow.Flow
interface TagRepository { interface TagRepository {
suspend fun getTags(): Result<List<ConversationTag>> fun observeTags(): Flow<List<ConversationTag>>
suspend fun updateConversationTags(conversationId: String, tags: List<String>): Result<Unit> suspend fun refreshTags(): Result<Unit>
suspend fun setConversationTags(conversationId: String, tags: List<String>): Result<Unit>
suspend fun toggleFavorite(conversationId: String, currentTags: List<String>): Result<Unit>
suspend fun clearCache()
} }

View file

@ -2,20 +2,50 @@ package com.garfiec.librechat.core.data.repository
import com.garfiec.librechat.core.common.result.Result import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.common.result.safeApiCall import com.garfiec.librechat.core.common.result.safeApiCall
import com.garfiec.librechat.core.data.db.dao.ConversationTagDao
import com.garfiec.librechat.core.data.mapper.toEntity
import com.garfiec.librechat.core.data.mapper.toModels
import com.garfiec.librechat.core.model.ConversationTag import com.garfiec.librechat.core.model.ConversationTag
import com.garfiec.librechat.core.model.SAVED_TAG
import com.garfiec.librechat.core.network.api.TagsApi import com.garfiec.librechat.core.network.api.TagsApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class TagRepositoryImpl( class TagRepositoryImpl(
private val tagsApi: TagsApi, private val tagsApi: TagsApi,
private val tagDao: ConversationTagDao,
private val conversationRepository: ConversationRepository,
) : TagRepository { ) : TagRepository {
override suspend fun getTags(): Result<List<ConversationTag>> = override fun observeTags(): Flow<List<ConversationTag>> =
safeApiCall { tagsApi.getTags() } tagDao.getAllTags().map { it.toModels() }
override suspend fun updateConversationTags( override suspend fun refreshTags(): Result<Unit> = safeApiCall {
val tags = tagsApi.getTags()
tagDao.replaceAll(tags.map { it.toEntity() })
}
override suspend fun setConversationTags(
conversationId: String, conversationId: String,
tags: List<String>, tags: List<String>,
): Result<Unit> = safeApiCall { ): Result<Unit> = safeApiCall {
tagsApi.updateConversationTags(conversationId, tags) tagsApi.updateConversationTags(conversationId, tags)
conversationRepository.updateConversationTagsLocal(conversationId, tags)
}
override suspend fun toggleFavorite(
conversationId: String,
currentTags: List<String>,
): Result<Unit> {
val newTags = if (SAVED_TAG in currentTags) {
currentTags.filterNot { it == SAVED_TAG }
} else {
currentTags + SAVED_TAG
}
return setConversationTags(conversationId, newTags)
}
override suspend fun clearCache() {
tagDao.deleteAll()
} }
} }

View file

@ -2,7 +2,6 @@ package com.garfiec.librechat.core.data.repository
import com.garfiec.librechat.core.common.result.Result import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.model.User import com.garfiec.librechat.core.model.User
import com.garfiec.librechat.core.model.UserFavorite
import com.garfiec.librechat.core.model.response.TermsResponse import com.garfiec.librechat.core.model.response.TermsResponse
interface UserRepository { interface UserRepository {
@ -13,6 +12,4 @@ interface UserRepository {
suspend fun resendVerification(email: String): Result<Unit> suspend fun resendVerification(email: String): Result<Unit>
suspend fun getTerms(): Result<TermsResponse> suspend fun getTerms(): Result<TermsResponse>
suspend fun acceptTerms(): Result<Unit> suspend fun acceptTerms(): Result<Unit>
suspend fun getFavorites(): Result<List<UserFavorite>>
suspend fun updateFavorites(favorites: List<UserFavorite>): Result<User>
} }

View file

@ -3,7 +3,6 @@ package com.garfiec.librechat.core.data.repository
import com.garfiec.librechat.core.common.result.Result import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.common.result.safeApiCall import com.garfiec.librechat.core.common.result.safeApiCall
import com.garfiec.librechat.core.model.User import com.garfiec.librechat.core.model.User
import com.garfiec.librechat.core.model.UserFavorite
import com.garfiec.librechat.core.model.request.ResendVerificationRequest import com.garfiec.librechat.core.model.request.ResendVerificationRequest
import com.garfiec.librechat.core.model.request.VerifyEmailRequest import com.garfiec.librechat.core.model.request.VerifyEmailRequest
import com.garfiec.librechat.core.model.response.TermsResponse import com.garfiec.librechat.core.model.response.TermsResponse
@ -40,12 +39,4 @@ class UserRepositoryImpl(
override suspend fun acceptTerms(): Result<Unit> = safeApiCall { override suspend fun acceptTerms(): Result<Unit> = safeApiCall {
userApi.acceptTerms() userApi.acceptTerms()
} }
override suspend fun getFavorites(): Result<List<UserFavorite>> = safeApiCall {
userApi.getFavorites()
}
override suspend fun updateFavorites(favorites: List<UserFavorite>): Result<User> = safeApiCall {
userApi.updateFavorites(favorites)
}
} }

View file

@ -0,0 +1,3 @@
package com.garfiec.librechat.core.model
const val SAVED_TAG = "Saved"

View file

@ -16,14 +16,6 @@ data class User(
val role: String = "USER", val role: String = "USER",
val twoFactorEnabled: Boolean = false, val twoFactorEnabled: Boolean = false,
val termsAccepted: Boolean = false, val termsAccepted: Boolean = false,
val favorites: List<UserFavorite> = emptyList(),
val createdAt: String? = null, val createdAt: String? = null,
val updatedAt: String? = null, val updatedAt: String? = null,
) )
@Serializable
data class UserFavorite(
val agentId: String? = null,
val model: String? = null,
val endpoint: String? = null,
)

View file

@ -1,16 +0,0 @@
package com.garfiec.librechat.core.model.request
import kotlinx.serialization.Serializable
@Serializable
data class CreateTagRequest(
val tag: String,
val description: String? = null,
val position: Int? = null,
)
@Serializable
data class UpdateTagRequest(
val description: String? = null,
val position: Int? = null,
)

View file

@ -30,10 +30,6 @@ class UserSerializationTest {
role = "ADMIN", role = "ADMIN",
twoFactorEnabled = true, twoFactorEnabled = true,
termsAccepted = true, termsAccepted = true,
favorites = listOf(
UserFavorite(agentId = "agent-1", model = "gpt-4o", endpoint = "openAI"),
UserFavorite(model = "claude-3-opus"),
),
createdAt = "2026-01-01T00:00:00Z", createdAt = "2026-01-01T00:00:00Z",
updatedAt = "2026-03-28T12:00:00Z", updatedAt = "2026-03-28T12:00:00Z",
) )
@ -55,9 +51,6 @@ class UserSerializationTest {
"role": "USER", "role": "USER",
"twoFactorEnabled": false, "twoFactorEnabled": false,
"termsAccepted": true, "termsAccepted": true,
"favorites": [
{"agentId": "a1", "endpoint": "agents"}
],
"extraField": "ignored" "extraField": "ignored"
} }
""".trimIndent() """.trimIndent()
@ -65,20 +58,6 @@ class UserSerializationTest {
assertEquals("user-srv", decoded.id) assertEquals("user-srv", decoded.id)
assertEquals("mongo-srv", decoded.mongoId) assertEquals("mongo-srv", decoded.mongoId)
assertEquals("Server User", decoded.name) assertEquals("Server User", decoded.name)
assertEquals(1, decoded.favorites.size)
assertEquals("a1", decoded.favorites[0].agentId)
}
@Test
fun userFavoriteRoundTrip() {
val original = UserFavorite(
agentId = "agent-x",
model = "gpt-4o-mini",
endpoint = "openAI",
)
val encoded = json.encodeToString(UserFavorite.serializer(), original)
val decoded = json.decodeFromString(UserFavorite.serializer(), encoded)
assertEquals(original, decoded)
} }
@Test @Test
@ -89,6 +68,5 @@ class UserSerializationTest {
assertEquals("USER", user.role) assertEquals("USER", user.role)
assertEquals(false, user.twoFactorEnabled) assertEquals(false, user.twoFactorEnabled)
assertEquals(false, user.termsAccepted) assertEquals(false, user.termsAccepted)
assertEquals(emptyList(), user.favorites)
} }
} }

View file

@ -42,7 +42,9 @@ class ConversationsApi constructor(
parameter("cursor", cursor) parameter("cursor", cursor)
parameter("limit", limit) parameter("limit", limit)
parameter("isArchived", isArchived) parameter("isArchived", isArchived)
tags?.forEach { tag -> parameter("tags[]", tag) } // Upstream's route handler reads req.query.tags directly, so we send
// repeated `tags=value` params rather than PHP-style `tags[]=value`.
tags?.forEach { tag -> parameter("tags", tag) }
search?.let { parameter("search", it) } search?.let { parameter("search", it) }
sortBy?.let { parameter("sortBy", it) } sortBy?.let { parameter("sortBy", it) }
sortDirection?.let { parameter("sortDirection", it) } sortDirection?.let { parameter("sortDirection", it) }

View file

@ -1,19 +1,15 @@
package com.garfiec.librechat.core.network.api package com.garfiec.librechat.core.network.api
import com.garfiec.librechat.core.model.ConversationTag import com.garfiec.librechat.core.model.ConversationTag
import com.garfiec.librechat.core.model.request.CreateTagRequest
import com.garfiec.librechat.core.model.request.TagUpdateRequest import com.garfiec.librechat.core.model.request.TagUpdateRequest
import com.garfiec.librechat.core.model.request.UpdateTagRequest
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.put import io.ktor.client.request.put
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.http.path import io.ktor.http.path
class TagsApi constructor( class TagsApi(
private val client: HttpClient, private val client: HttpClient,
) { ) {
suspend fun getTags(): List<ConversationTag> = suspend fun getTags(): List<ConversationTag> =
@ -21,24 +17,6 @@ class TagsApi constructor(
url { path("api/tags") } url { path("api/tags") }
}.body() }.body()
suspend fun createTag(request: CreateTagRequest): ConversationTag =
client.post {
url { path("api/tags") }
setBody(request)
}.body()
suspend fun updateTag(tag: String, request: UpdateTagRequest): ConversationTag =
client.put {
url { path("api/tags/$tag") }
setBody(request)
}.body()
suspend fun deleteTag(tag: String) {
client.delete {
url { path("api/tags/$tag") }
}
}
suspend fun updateConversationTags(conversationId: String, tags: List<String>) { suspend fun updateConversationTags(conversationId: String, tags: List<String>) {
client.put { client.put {
url { path("api/tags/convo/$conversationId") } url { path("api/tags/convo/$conversationId") }

View file

@ -1,12 +1,10 @@
package com.garfiec.librechat.core.network.api package com.garfiec.librechat.core.network.api
import com.garfiec.librechat.core.model.User import com.garfiec.librechat.core.model.User
import com.garfiec.librechat.core.model.UserFavorite
import com.garfiec.librechat.core.model.request.OtpVerificationRequest import com.garfiec.librechat.core.model.request.OtpVerificationRequest
import com.garfiec.librechat.core.model.request.ResendVerificationRequest import com.garfiec.librechat.core.model.request.ResendVerificationRequest
import com.garfiec.librechat.core.model.request.VerifyEmailRequest import com.garfiec.librechat.core.model.request.VerifyEmailRequest
import com.garfiec.librechat.core.model.response.TermsResponse import com.garfiec.librechat.core.model.response.TermsResponse
import com.garfiec.librechat.core.network.api.dto.UpdateFavoritesRequest
import com.garfiec.librechat.core.network.api.dto.UpdatePluginsRequest import com.garfiec.librechat.core.network.api.dto.UpdatePluginsRequest
import com.garfiec.librechat.core.network.api.dto.UserUpdateRequest import com.garfiec.librechat.core.network.api.dto.UserUpdateRequest
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
@ -82,17 +80,6 @@ class UserApi constructor(
url { path("api/user/avatar") } url { path("api/user/avatar") }
}.body() }.body()
suspend fun getFavorites(): List<UserFavorite> =
client.get {
url { path("api/user/favorites") }
}.body()
suspend fun updateFavorites(favorites: List<UserFavorite>): User =
client.post {
url { path("api/user/favorites") }
setBody(UpdateFavoritesRequest(favorites = favorites))
}.body()
suspend fun updatePlugins(plugins: List<String>): User = suspend fun updatePlugins(plugins: List<String>): User =
client.post { client.post {
url { path("api/user/plugins") } url { path("api/user/plugins") }

View file

@ -1,9 +0,0 @@
package com.garfiec.librechat.core.network.api.dto
import com.garfiec.librechat.core.model.UserFavorite
import kotlinx.serialization.Serializable
@Serializable
data class UpdateFavoritesRequest(
val favorites: List<UserFavorite>,
)

View file

@ -22,7 +22,7 @@
## Tags ## Tags
- `TagFilterBar` displays `FilterChip` per tag (multi-select toggle) - `TagFilterBar` displays `FilterChip` per tag (multi-select toggle)
- `TagPicker` for assigning tags to a conversation - `TagPicker` for assigning tags to a conversation
- Tags fetched via `TagRepository.getTags()`, hidden if `count == 0` - Tags observed via `TagRepository.observeTags()` (Room-backed Flow); `SAVED_TAG` and `count == 0` tags are filtered out in the ViewModel
- `toggleTag()` / `clearTagFilter()` reload conversations with new filter - `toggleTag()` / `clearTagFilter()` reload conversations with new filter
## CRUD Actions (via `ConversationActions` component) ## CRUD Actions (via `ConversationActions` component)

View file

@ -3,7 +3,6 @@ package com.garfiec.librechat.feature.conversations.di
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import com.garfiec.librechat.core.data.datastore.ServerDataStore import com.garfiec.librechat.core.data.datastore.ServerDataStore
import com.garfiec.librechat.core.data.datastore.SettingsDataStore
import com.garfiec.librechat.core.data.repository.ConfigRepository import com.garfiec.librechat.core.data.repository.ConfigRepository
import com.garfiec.librechat.core.data.repository.ConversationRepository import com.garfiec.librechat.core.data.repository.ConversationRepository
import com.garfiec.librechat.core.data.repository.MessageRepository import com.garfiec.librechat.core.data.repository.MessageRepository
@ -27,7 +26,6 @@ class ConversationsModuleVerificationTest {
SearchRepository::class, SearchRepository::class,
ConfigRepository::class, ConfigRepository::class,
ServerDataStore::class, ServerDataStore::class,
SettingsDataStore::class,
), ),
) )
} }

View file

@ -1,13 +1,13 @@
package com.garfiec.librechat.feature.conversations.viewmodel package com.garfiec.librechat.feature.conversations.viewmodel
import com.garfiec.librechat.core.common.result.Result import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.data.datastore.SettingsDataStore
import com.garfiec.librechat.core.data.repository.ConversationRepository import com.garfiec.librechat.core.data.repository.ConversationRepository
import com.garfiec.librechat.core.data.repository.SearchRepository import com.garfiec.librechat.core.data.repository.SearchRepository
import com.garfiec.librechat.core.data.repository.ShareRepository import com.garfiec.librechat.core.data.repository.ShareRepository
import com.garfiec.librechat.core.data.repository.TagRepository import com.garfiec.librechat.core.data.repository.TagRepository
import com.garfiec.librechat.core.model.Conversation import com.garfiec.librechat.core.model.Conversation
import com.garfiec.librechat.core.model.ConversationTag import com.garfiec.librechat.core.model.ConversationTag
import com.garfiec.librechat.core.model.SAVED_TAG
import com.garfiec.librechat.feature.conversations.export.ConversationExporter import com.garfiec.librechat.feature.conversations.export.ConversationExporter
import com.garfiec.librechat.feature.conversations.export.ConversationImporter import com.garfiec.librechat.feature.conversations.export.ConversationImporter
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
@ -17,7 +17,6 @@ import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
@ -40,7 +39,6 @@ class ConversationListViewModelTest {
private val shareRepository = mockk<ShareRepository>(relaxed = true) private val shareRepository = mockk<ShareRepository>(relaxed = true)
private val conversationExporter = mockk<ConversationExporter>(relaxed = true) private val conversationExporter = mockk<ConversationExporter>(relaxed = true)
private val conversationImporter = mockk<ConversationImporter>(relaxed = true) private val conversationImporter = mockk<ConversationImporter>(relaxed = true)
private val settingsDataStore = mockk<SettingsDataStore>(relaxed = true)
private lateinit var viewModel: ConversationListViewModel private lateinit var viewModel: ConversationListViewModel
@ -61,6 +59,7 @@ class ConversationListViewModelTest {
ConversationTag(tag = "work", count = 3), ConversationTag(tag = "work", count = 3),
ConversationTag(tag = "personal", count = 2), ConversationTag(tag = "personal", count = 2),
ConversationTag(tag = "empty", count = 0), ConversationTag(tag = "empty", count = 0),
ConversationTag(tag = SAVED_TAG, count = 5),
) )
@Before @Before
@ -70,9 +69,10 @@ class ConversationListViewModelTest {
every { conversationRepository.observeConversations(any()) } returns flowOf( every { conversationRepository.observeConversations(any()) } returns flowOf(
Result.Success(testConversations), Result.Success(testConversations),
) )
every { settingsDataStore.bookmarkedConversationIds } returns MutableStateFlow(emptySet())
coEvery { conversationRepository.loadNextPage(any(), tags = any()) } returns Result.Success(null) coEvery { conversationRepository.loadNextPage(any(), tags = any()) } returns Result.Success(null)
coEvery { tagRepository.getTags() } returns Result.Success(testTags) coEvery { conversationRepository.syncFavoritesFromServer() } returns Result.Success(Unit)
every { tagRepository.observeTags() } returns flowOf(testTags)
coEvery { tagRepository.refreshTags() } returns Result.Success(Unit)
} }
@After @After
@ -87,7 +87,6 @@ class ConversationListViewModelTest {
shareRepository = shareRepository, shareRepository = shareRepository,
conversationExporter = conversationExporter, conversationExporter = conversationExporter,
conversationImporter = conversationImporter, conversationImporter = conversationImporter,
settingsDataStore = settingsDataStore,
) )
@Test @Test
@ -101,7 +100,7 @@ class ConversationListViewModelTest {
} }
@Test @Test
fun `initial state loads tags with non-zero counts`() = runTest { fun `initial state loads tags with non-zero counts and excludes SAVED_TAG`() = runTest {
viewModel = createViewModel() viewModel = createViewModel()
advanceUntilIdle() advanceUntilIdle()
@ -433,16 +432,106 @@ class ConversationListViewModelTest {
} }
@Test @Test
fun `updateConversationTags reloads on success`() = runTest { fun `updateConversationTags persists new tag list when conversation was not favorited`() = runTest {
coEvery { tagRepository.updateConversationTags("convo-1", listOf("work")) } returns coEvery { tagRepository.setConversationTags("convo-1", listOf("work")) } returns
Result.Success(Unit) Result.Success(Unit)
viewModel = createViewModel() viewModel = createViewModel()
advanceUntilIdle() advanceUntilIdle()
viewModel.updateConversationTags("convo-1", listOf("work")) val convo = Conversation(conversationId = "convo-1", tags = listOf("old"))
viewModel.updateConversationTags(convo, listOf("work"))
advanceUntilIdle() advanceUntilIdle()
coVerify { tagRepository.updateConversationTags("convo-1", listOf("work")) } coVerify { tagRepository.setConversationTags("convo-1", listOf("work")) }
}
@Test
fun `updateConversationTags preserves SAVED_TAG when conversation was favorited`() = runTest {
coEvery { tagRepository.setConversationTags(any(), any()) } returns Result.Success(Unit)
viewModel = createViewModel()
advanceUntilIdle()
val convo = Conversation(conversationId = "convo-1", tags = listOf("work", SAVED_TAG))
// Picker submits the user-tag list WITHOUT SAVED_TAG (the screen strips it).
viewModel.updateConversationTags(convo, listOf("projects"))
advanceUntilIdle()
coVerify { tagRepository.setConversationTags("convo-1", listOf("projects", SAVED_TAG)) }
}
@Test
fun `updateConversationTags strips SAVED_TAG from picker submission when not favorited`() = runTest {
coEvery { tagRepository.setConversationTags(any(), any()) } returns Result.Success(Unit)
viewModel = createViewModel()
advanceUntilIdle()
val convo = Conversation(conversationId = "convo-1", tags = listOf("work"))
// Defensive: if the picker ever submits SAVED_TAG on an unfavorited convo, drop it.
viewModel.updateConversationTags(convo, listOf("projects", SAVED_TAG))
advanceUntilIdle()
coVerify { tagRepository.setConversationTags("convo-1", listOf("projects")) }
}
@Test
fun `toggleFavorite on unfavorited convo delegates to repo`() = runTest {
coEvery {
tagRepository.toggleFavorite(any(), any())
} returns Result.Success(Unit)
viewModel = createViewModel()
advanceUntilIdle()
val convo = Conversation(conversationId = "convo-1", tags = listOf("work"))
viewModel.toggleFavorite(convo)
advanceUntilIdle()
coVerify {
tagRepository.toggleFavorite(
conversationId = "convo-1",
currentTags = listOf("work"),
)
}
}
@Test
fun `toggleFavorite on favorited convo delegates to repo`() = runTest {
coEvery {
tagRepository.toggleFavorite(any(), any())
} returns Result.Success(Unit)
viewModel = createViewModel()
advanceUntilIdle()
val convo = Conversation(
conversationId = "convo-1",
tags = listOf("work", SAVED_TAG),
)
viewModel.toggleFavorite(convo)
advanceUntilIdle()
coVerify {
tagRepository.toggleFavorite(
conversationId = "convo-1",
currentTags = listOf("work", SAVED_TAG),
)
}
}
@Test
fun `toggleFavorite with null conversationId is a no-op`() = runTest {
viewModel = createViewModel()
advanceUntilIdle()
val convo = Conversation(conversationId = null, tags = listOf("work"))
viewModel.toggleFavorite(convo)
advanceUntilIdle()
coVerify(exactly = 0) {
tagRepository.toggleFavorite(any(), any())
}
} }
} }

View file

@ -3,6 +3,7 @@ package com.garfiec.librechat.feature.conversations.components
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
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.model.SAVED_TAG
@Immutable @Immutable
data class ConversationDisplayData( data class ConversationDisplayData(
@ -14,11 +15,11 @@ data class ConversationDisplayData(
val isBookmarked: Boolean, val isBookmarked: Boolean,
) )
fun Conversation.toDisplayData(bookmarkedIds: Set<String>) = ConversationDisplayData( fun Conversation.toDisplayData() = ConversationDisplayData(
conversationId = conversationId ?: "", conversationId = conversationId ?: "",
title = title ?: "New Chat", title = title ?: "New Chat",
endpoint = endpoint, endpoint = endpoint,
model = model, model = model,
updatedAt = updatedAt, updatedAt = updatedAt,
isBookmarked = conversationId in bookmarkedIds, isBookmarked = SAVED_TAG in tags,
) )

View file

@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.garfiec.librechat.core.model.Conversation import com.garfiec.librechat.core.model.Conversation
import com.garfiec.librechat.core.model.SAVED_TAG
import com.garfiec.librechat.core.ui.components.EmptyState import com.garfiec.librechat.core.ui.components.EmptyState
import com.garfiec.librechat.core.ui.components.ErrorBanner import com.garfiec.librechat.core.ui.components.ErrorBanner
import com.garfiec.librechat.core.ui.components.LoadingIndicator import com.garfiec.librechat.core.ui.components.LoadingIndicator
@ -326,12 +327,10 @@ fun ConversationListScreen(
ConversationActions( ConversationActions(
conversation = selectedConversation!!, conversation = selectedConversation!!,
onDismiss = { selectedConversation = null }, onDismiss = { selectedConversation = null },
isBookmarked = selectedConversation?.conversationId?.let { isBookmarked = SAVED_TAG in (selectedConversation?.tags ?: emptyList()),
viewModel.isBookmarked(it)
} ?: false,
onBookmarkToggle = { onBookmarkToggle = {
selectedConversation?.conversationId?.let { id -> selectedConversation?.let { convo ->
viewModel.toggleBookmark(id) viewModel.toggleFavorite(convo)
} }
selectedConversation = null selectedConversation = null
}, },
@ -383,10 +382,10 @@ fun ConversationListScreen(
if (showTagPicker && tagPickerConversation != null) { if (showTagPicker && tagPickerConversation != null) {
TagPicker( TagPicker(
availableTags = uiState.tags, availableTags = uiState.tags,
currentTags = tagPickerConversation?.tags ?: emptyList(), currentTags = tagPickerConversation?.tags?.filterNot { it == SAVED_TAG } ?: emptyList(),
onTagsChange = { newTags -> onTagsChange = { newTags ->
tagPickerConversation?.conversationId?.let { id -> tagPickerConversation?.let { convo ->
viewModel.updateConversationTags(id, newTags) viewModel.updateConversationTags(convo, newTags)
} }
}, },
onDismiss = { onDismiss = {

View file

@ -7,19 +7,20 @@ import co.touchlab.kermit.Logger
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.toRelativeDateGroup
import com.garfiec.librechat.core.common.result.Result import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.data.datastore.SettingsDataStore
import com.garfiec.librechat.core.data.repository.ConversationRepository import com.garfiec.librechat.core.data.repository.ConversationRepository
import com.garfiec.librechat.core.data.repository.SearchRepository import com.garfiec.librechat.core.data.repository.SearchRepository
import com.garfiec.librechat.core.data.repository.ShareRepository import com.garfiec.librechat.core.data.repository.ShareRepository
import com.garfiec.librechat.core.data.repository.TagRepository import com.garfiec.librechat.core.data.repository.TagRepository
import com.garfiec.librechat.core.model.Conversation import com.garfiec.librechat.core.model.Conversation
import com.garfiec.librechat.core.model.ConversationTag import com.garfiec.librechat.core.model.ConversationTag
import com.garfiec.librechat.core.model.SAVED_TAG
import com.garfiec.librechat.feature.conversations.components.ConversationDisplayData import com.garfiec.librechat.feature.conversations.components.ConversationDisplayData
import com.garfiec.librechat.feature.conversations.components.toDisplayData import com.garfiec.librechat.feature.conversations.components.toDisplayData
import com.garfiec.librechat.feature.conversations.export.ConversationExporter import com.garfiec.librechat.feature.conversations.export.ConversationExporter
import com.garfiec.librechat.feature.conversations.export.ConversationImporter import com.garfiec.librechat.feature.conversations.export.ConversationImporter
import com.garfiec.librechat.feature.conversations.export.ExportFormat import com.garfiec.librechat.feature.conversations.export.ExportFormat
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -59,7 +60,6 @@ class ConversationListViewModel(
private val shareRepository: ShareRepository, private val shareRepository: ShareRepository,
private val conversationExporter: ConversationExporter, private val conversationExporter: ConversationExporter,
private val conversationImporter: ConversationImporter, private val conversationImporter: ConversationImporter,
private val settingsDataStore: SettingsDataStore,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(ConversationListUiState()) private val _uiState = MutableStateFlow(ConversationListUiState())
@ -70,14 +70,12 @@ class ConversationListViewModel(
/** Raw conversations kept for internal lookups (action sheets, etc.). */ /** Raw conversations kept for internal lookups (action sheets, etc.). */
private var conversations: List<Conversation> = emptyList() private var conversations: List<Conversation> = emptyList()
private var bookmarkedIds: Set<String> = emptySet()
private var searchJob: Job? = null private var searchJob: Job? = null
init { init {
loadConversations() loadConversations()
observeConversations() observeConversations()
observeBookmarks() observeTags()
loadTags()
} }
private fun observeConversations() { private fun observeConversations() {
@ -104,17 +102,18 @@ class ConversationListViewModel(
} }
} }
private fun observeBookmarks() { private fun observeTags() {
viewModelScope.launch { viewModelScope.launch {
settingsDataStore.bookmarkedConversationIds.collect { ids -> tagRepository.observeTags().collect { tags ->
bookmarkedIds = ids _uiState.value = _uiState.value.copy(
recomputeGroupedConversations() tags = tags.filter { it.count > 0 && it.tag != SAVED_TAG },
)
} }
} }
} }
private fun recomputeGroupedConversations() { private fun recomputeGroupedConversations() {
val grouped = groupConversationsByDate(conversations, bookmarkedIds) val grouped = groupConversationsByDate(conversations)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
groupedConversations = grouped, groupedConversations = grouped,
conversationCount = conversations.size, conversationCount = conversations.size,
@ -124,12 +123,20 @@ class ConversationListViewModel(
fun getConversation(conversationId: String): Conversation? = fun getConversation(conversationId: String): Conversation? =
conversations.firstOrNull { it.conversationId == conversationId } conversations.firstOrNull { it.conversationId == conversationId }
fun isBookmarked(conversationId: String): Boolean = fun toggleFavorite(conversation: Conversation) {
conversationId in bookmarkedIds val id = conversation.conversationId ?: return
fun toggleBookmark(conversationId: String) {
viewModelScope.launch { viewModelScope.launch {
settingsDataStore.toggleBookmark(conversationId) when (val result = tagRepository.toggleFavorite(id, conversation.tags)) {
is Result.Error -> {
_events.emit(
ConversationListEvent.ShowError(
result.message ?: "Failed to update favorite",
),
)
}
is Result.Success -> { /* no-op */ }
is Result.Loading -> { /* no-op */ }
}
} }
} }
@ -181,40 +188,25 @@ class ConversationListViewModel(
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isRefreshing = true) _uiState.value = _uiState.value.copy(isRefreshing = true)
val activeTags = _uiState.value.selectedTags.toList().ifEmpty { null } val activeTags = _uiState.value.selectedTags.toList().ifEmpty { null }
when (val result = conversationRepository.loadNextPage(null, tags = activeTags)) { coroutineScope {
is Result.Success -> { launch {
_uiState.value = _uiState.value.copy( when (val result = conversationRepository.loadNextPage(null, tags = activeTags)) {
isRefreshing = false, is Result.Success -> {
nextCursor = result.data, _uiState.value = _uiState.value.copy(
hasMore = result.data != null, nextCursor = result.data,
) hasMore = result.data != null,
)
}
is Result.Error -> {
_uiState.value = _uiState.value.copy(error = result.message)
}
is Result.Loading -> { /* no-op */ }
}
} }
is Result.Error -> { launch { tagRepository.refreshTags() }
_uiState.value = _uiState.value.copy( launch { conversationRepository.syncFavoritesFromServer() }
isRefreshing = false,
error = result.message,
)
}
is Result.Loading -> { /* no-op */ }
}
loadTags()
}
}
private fun loadTags() {
viewModelScope.launch {
when (val result = tagRepository.getTags()) {
is Result.Success -> {
_uiState.value = _uiState.value.copy(
tags = result.data.filter { it.count > 0 },
)
}
is Result.Error -> {
// Tags are non-critical, silently fail
Logger.d(result.exception) { "Failed to load tags: ${result.message}" }
}
is Result.Loading -> { /* no-op */ }
} }
_uiState.value = _uiState.value.copy(isRefreshing = false)
} }
} }
@ -265,12 +257,17 @@ class ConversationListViewModel(
} }
} }
fun updateConversationTags(conversationId: String, tags: List<String>) { fun updateConversationTags(conversation: Conversation, userTags: List<String>) {
val id = conversation.conversationId ?: return
val wasFavorited = SAVED_TAG in conversation.tags
val cleaned = userTags.filterNot { it == SAVED_TAG }
val finalTags = if (wasFavorited) cleaned + SAVED_TAG else cleaned
viewModelScope.launch { viewModelScope.launch {
when (tagRepository.updateConversationTags(conversationId, tags)) { when (tagRepository.setConversationTags(id, finalTags)) {
is Result.Success -> { is Result.Success -> {
loadTags() // Room Flow already emits the updated conversation via
refresh() // observeConversations; we only need fresh tag counts.
tagRepository.refreshTags()
} }
is Result.Error -> { is Result.Error -> {
_events.emit(ConversationListEvent.ShowError("Failed to update tags")) _events.emit(ConversationListEvent.ShowError("Failed to update tags"))
@ -434,7 +431,6 @@ class ConversationListViewModel(
companion object { companion object {
private fun groupConversationsByDate( private fun groupConversationsByDate(
conversations: List<Conversation>, conversations: List<Conversation>,
bookmarkedIds: Set<String>,
): List<Pair<String, List<ConversationDisplayData>>> { ): List<Pair<String, List<ConversationDisplayData>>> {
if (conversations.isEmpty()) return emptyList() if (conversations.isEmpty()) return emptyList()
return conversations return conversations
@ -445,7 +441,7 @@ class ConversationListViewModel(
?: "Unknown" ?: "Unknown"
} }
.map { (group, convos) -> .map { (group, convos) ->
group to convos.map { it.toDisplayData(bookmarkedIds) } group to convos.map { it.toDisplayData() }
} }
} }
} }

View file

@ -100,7 +100,6 @@ class SettingsViewModelTest {
every { settingsDataStore.showAvatars } returns MutableStateFlow(true) every { settingsDataStore.showAvatars } returns MutableStateFlow(true)
every { settingsDataStore.showBubbles } returns MutableStateFlow(false) every { settingsDataStore.showBubbles } returns MutableStateFlow(false)
every { settingsDataStore.latexRenderer } returns MutableStateFlow(LatexRenderer.KATEX) every { settingsDataStore.latexRenderer } returns MutableStateFlow(LatexRenderer.KATEX)
every { settingsDataStore.bookmarkedConversationIds } returns MutableStateFlow(emptySet())
// Setup default API responses // Setup default API responses
coEvery { userRepository.getUser() } returns Result.Success(testUser) coEvery { userRepository.getUser() } returns Result.Success(testUser)

View file

@ -8,6 +8,7 @@ import com.garfiec.librechat.core.data.repository.ConversationRepository
import com.garfiec.librechat.core.model.Conversation import com.garfiec.librechat.core.model.Conversation
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
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
@ -108,7 +109,10 @@ class ConversationListStateHolder(
scope.launch { scope.launch {
nextCursor = null nextCursor = null
_hasMore.value = true _hasMore.value = true
loadPage(cursor = null) coroutineScope {
launch { loadPage(cursor = null) }
launch { conversationRepository.syncFavoritesFromServer() }
}
_isRefreshing.value = false _isRefreshing.value = false
} }
} }

View file

@ -95,7 +95,7 @@ fun DrawerContent(
onSettingsClick = onSettingsClick, onSettingsClick = onSettingsClick,
onAgentsClick = onAgentsClick, onAgentsClick = onAgentsClick,
onFilesClick = onFilesClick, onFilesClick = onFilesClick,
onToggleFavorite = viewModel::toggleFavorite, onToggleFavorite = { data -> viewModel.toggleFavorite(data.conversationId, data.tags) },
onRefresh = viewModel::refreshConversations, onRefresh = viewModel::refreshConversations,
onLoadMore = viewModel::loadMoreConversations, onLoadMore = viewModel::loadMoreConversations,
modifier = modifier, modifier = modifier,
@ -113,7 +113,7 @@ fun DrawerContent(
onAgentsClick: () -> Unit, onAgentsClick: () -> Unit,
onFilesClick: () -> Unit, onFilesClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onToggleFavorite: (String) -> Unit = {}, onToggleFavorite: (DrawerConversationDisplayData) -> Unit = {},
onRefresh: () -> Unit = {}, onRefresh: () -> Unit = {},
onLoadMore: () -> Unit = {}, onLoadMore: () -> Unit = {},
) { ) {
@ -257,7 +257,7 @@ fun DrawerContent(
DrawerConversationItem( DrawerConversationItem(
data = data, data = data,
onClick = { onConversationClick(data.conversationId) }, onClick = { onConversationClick(data.conversationId) },
onToggleFavorite = { onToggleFavorite(data.conversationId) }, onToggleFavorite = { onToggleFavorite(data) },
) )
} }
@ -303,7 +303,7 @@ fun DrawerContent(
DrawerConversationItem( DrawerConversationItem(
data = data, data = data,
onClick = { onConversationClick(data.conversationId) }, onClick = { onConversationClick(data.conversationId) },
onToggleFavorite = { onToggleFavorite(data.conversationId) }, onToggleFavorite = { onToggleFavorite(data) },
) )
} }
} }

View file

@ -3,6 +3,7 @@ package com.garfiec.librechat.shared.navigation
import com.garfiec.librechat.core.common.extensions.formatMonthAbbrev import com.garfiec.librechat.core.common.extensions.formatMonthAbbrev
import com.garfiec.librechat.core.common.extensions.toInstantOrNull import com.garfiec.librechat.core.common.extensions.toInstantOrNull
import com.garfiec.librechat.core.model.Conversation import com.garfiec.librechat.core.model.Conversation
import com.garfiec.librechat.core.model.SAVED_TAG
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock import kotlin.time.Clock
@ -11,14 +12,12 @@ import kotlin.time.Instant
internal data class DrawerDataSnapshot( internal data class DrawerDataSnapshot(
val grouped: List<Pair<String, List<Conversation>>>, val grouped: List<Pair<String, List<Conversation>>>,
val activeId: String?, val activeId: String?,
val favIds: Set<String>,
val favConvos: List<Conversation>, val favConvos: List<Conversation>,
val query: String, val query: String,
) )
internal fun Conversation.toDrawerDisplayData( internal fun Conversation.toDrawerDisplayData(
activeConversationId: String?, activeConversationId: String?,
favoriteIds: Set<String>,
): DrawerConversationDisplayData { ): DrawerConversationDisplayData {
val convId = conversationId ?: "" val convId = conversationId ?: ""
return DrawerConversationDisplayData( return DrawerConversationDisplayData(
@ -28,7 +27,8 @@ internal fun Conversation.toDrawerDisplayData(
endpoint = endpoint, endpoint = endpoint,
relativeTime = updatedAt?.toInstantOrNull()?.toRelativeTimeString() ?: "", relativeTime = updatedAt?.toInstantOrNull()?.toRelativeTimeString() ?: "",
isActive = convId == activeConversationId, isActive = convId == activeConversationId,
isFavorite = convId in favoriteIds, isFavorite = SAVED_TAG in tags,
tags = tags,
) )
} }

View file

@ -16,6 +16,7 @@ data class DrawerConversationDisplayData(
val relativeTime: String, val relativeTime: String,
val isActive: Boolean, val isActive: Boolean,
val isFavorite: Boolean, val isFavorite: Boolean,
val tags: List<String>,
) )
/** /**

View file

@ -1,72 +0,0 @@
package com.garfiec.librechat.shared.navigation
import co.touchlab.kermit.Logger
import com.garfiec.librechat.core.data.datastore.SettingsDataStore
import com.garfiec.librechat.core.model.Conversation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class FavoritesStateHolder(
private val settingsDataStore: SettingsDataStore,
private val recentConversations: StateFlow<List<Conversation>>,
private val scope: CoroutineScope,
) {
private val _favorites = MutableStateFlow<Set<String>>(emptySet())
val favorites: StateFlow<Set<String>> = _favorites.asStateFlow()
private val _favoriteConversations = MutableStateFlow<List<Conversation>>(emptyList())
val favoriteConversations: StateFlow<List<Conversation>> = _favoriteConversations.asStateFlow()
init {
observeBookmarks()
observeRecentConversations()
}
private fun observeBookmarks() {
scope.launch {
try {
settingsDataStore.bookmarkedConversationIds.collect { bookmarkedIds ->
_favorites.value = bookmarkedIds
updateFavoriteConversations()
}
} catch (e: Exception) {
Logger.w(e) { "Failed to observe bookmarks" }
}
}
}
private fun observeRecentConversations() {
scope.launch {
recentConversations.collect {
updateFavoriteConversations()
}
}
}
fun toggleFavorite(conversationId: String) {
scope.launch {
settingsDataStore.toggleBookmark(conversationId)
}
}
private fun updateFavoriteConversations() {
val bookmarkedIds = _favorites.value
val allConversations = recentConversations.value
_favoriteConversations.value = allConversations.filter { conversation ->
conversation.conversationId in bookmarkedIds ||
conversation.tags.any {
it.equals("favorite", ignoreCase = true) ||
it.equals("bookmark", ignoreCase = true)
}
}
}
fun reset() {
_favorites.value = emptySet()
_favoriteConversations.value = emptyList()
}
}

View file

@ -4,12 +4,16 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
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.result.Result
import com.garfiec.librechat.core.data.datastore.SettingsDataStore import com.garfiec.librechat.core.data.datastore.SettingsDataStore
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
import com.garfiec.librechat.core.data.repository.ConversationRepository import com.garfiec.librechat.core.data.repository.ConversationRepository
import com.garfiec.librechat.core.data.repository.TagRepository
import com.garfiec.librechat.core.model.Banner import com.garfiec.librechat.core.model.Banner
import com.garfiec.librechat.core.model.Conversation
import com.garfiec.librechat.core.model.SAVED_TAG
import com.garfiec.librechat.core.network.client.TokenManager import com.garfiec.librechat.core.network.client.TokenManager
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@ -17,6 +21,7 @@ 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.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -24,7 +29,8 @@ class NavHostViewModel(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
bannerRepository: BannerRepository, bannerRepository: BannerRepository,
configRepository: ConfigRepository, configRepository: ConfigRepository,
conversationRepository: ConversationRepository, private val conversationRepository: ConversationRepository,
private val tagRepository: TagRepository,
private val tokenManager: TokenManager, private val tokenManager: TokenManager,
private val settingsDataStore: SettingsDataStore, private val settingsDataStore: SettingsDataStore,
) : ViewModel() { ) : ViewModel() {
@ -32,8 +38,11 @@ class NavHostViewModel(
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 conversationListStateHolder = ConversationListStateHolder(conversationRepository, viewModelScope)
private val favoritesStateHolder =
FavoritesStateHolder(settingsDataStore, conversationListStateHolder.recentConversations, viewModelScope) private val favoriteConversations: StateFlow<List<Conversation>> =
conversationListStateHolder.recentConversations
.map { list -> list.filter { SAVED_TAG in it.tags } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
private val _isLoggedIn = MutableStateFlow(tokenManager.isAuthenticated) private val _isLoggedIn = MutableStateFlow(tokenManager.isAuthenticated)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow() val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
@ -73,11 +82,10 @@ class NavHostViewModel(
combine( combine(
conversationListStateHolder.groupedConversations, conversationListStateHolder.groupedConversations,
conversationListStateHolder.activeConversationId, conversationListStateHolder.activeConversationId,
favoritesStateHolder.favorites, favoriteConversations,
favoritesStateHolder.favoriteConversations,
conversationListStateHolder.searchQuery, conversationListStateHolder.searchQuery,
) { grouped, activeId, favIds, favConvos, query -> ) { grouped, activeId, favConvos, query ->
DrawerDataSnapshot(grouped, activeId, favIds, favConvos, query) DrawerDataSnapshot(grouped, activeId, favConvos, query)
}, },
combine( combine(
conversationListStateHolder.isRefreshing, conversationListStateHolder.isRefreshing,
@ -89,9 +97,9 @@ class NavHostViewModel(
) { data, (refreshing, loadingMore, hasMore) -> ) { data, (refreshing, loadingMore, hasMore) ->
DrawerUiState( DrawerUiState(
groupedConversations = data.grouped.map { (group, convos) -> groupedConversations = data.grouped.map { (group, convos) ->
group to convos.map { it.toDrawerDisplayData(data.activeId, data.favIds) } group to convos.map { it.toDrawerDisplayData(data.activeId) }
}, },
favoriteConversations = data.favConvos.map { it.toDrawerDisplayData(data.activeId, data.favIds) }, favoriteConversations = data.favConvos.map { it.toDrawerDisplayData(data.activeId) },
searchQuery = data.query, searchQuery = data.query,
isRefreshing = refreshing, isRefreshing = refreshing,
isLoadingMore = loadingMore, isLoadingMore = loadingMore,
@ -106,6 +114,8 @@ class NavHostViewModel(
_isLoggedIn.value = loggedIn _isLoggedIn.value = loggedIn
if (loggedIn) { if (loggedIn) {
versionCheckStateHolder.checkBackendVersion() versionCheckStateHolder.checkBackendVersion()
launch { tagRepository.refreshTags() }
launch { conversationRepository.syncFavoritesFromServer() }
} }
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(e) { "Failed to check auth state on init" } Logger.w(e) { "Failed to check auth state on init" }
@ -121,12 +131,17 @@ class NavHostViewModel(
fun onAuthComplete() { fun onAuthComplete() {
_isLoggedIn.value = true _isLoggedIn.value = true
conversationListStateHolder.refreshConversations() conversationListStateHolder.refreshConversations()
viewModelScope.launch {
launch { tagRepository.refreshTags() }
launch { conversationRepository.syncFavoritesFromServer() }
}
bannerStateHolder.fetchBanners() bannerStateHolder.fetchBanners()
versionCheckStateHolder.checkBackendVersion() versionCheckStateHolder.checkBackendVersion()
} }
fun refreshConversations() { fun refreshConversations() {
conversationListStateHolder.refreshConversations() conversationListStateHolder.refreshConversations()
viewModelScope.launch { tagRepository.refreshTags() }
} }
fun setActiveConversation(conversationId: String?) { fun setActiveConversation(conversationId: String?) {
@ -137,8 +152,13 @@ class NavHostViewModel(
conversationListStateHolder.onSearchQueryChanged(query) conversationListStateHolder.onSearchQueryChanged(query)
} }
fun toggleFavorite(conversationId: String) { fun toggleFavorite(conversationId: String, currentTags: List<String>) {
favoritesStateHolder.toggleFavorite(conversationId) viewModelScope.launch {
val result = tagRepository.toggleFavorite(conversationId, currentTags)
if (result is Result.Error) {
Logger.w(result.exception) { "Failed to toggle favorite for $conversationId" }
}
}
} }
fun setTabletSidebarOpen(open: Boolean) { fun setTabletSidebarOpen(open: Boolean) {
@ -164,7 +184,7 @@ class NavHostViewModel(
authRepository.logout() authRepository.logout()
_isLoggedIn.value = false _isLoggedIn.value = false
conversationListStateHolder.reset() conversationListStateHolder.reset()
favoritesStateHolder.reset() tagRepository.clearCache()
_sidebarMode.value = SidebarMode.Conversations _sidebarMode.value = SidebarMode.Conversations
_selectedSettingsCategory.value = null _selectedSettingsCategory.value = null
} }