diff --git a/app/CLAUDE.md b/app/CLAUDE.md index 3ab0492..873e03c 100644 --- a/app/CLAUDE.md +++ b/app/CLAUDE.md @@ -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. It applies convention plugins: `librechat.mobile.application`, `librechat.mobile.compose`, `librechat.mobile.koin`. -### Server-Synced Favorites -- `NavHostViewModel` (shared) exposes `favorites: StateFlow>` 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 - `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>` (session-scoped, not persisted) diff --git a/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/datastore/DataStoreRoundTripTest.kt b/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/datastore/DataStoreRoundTripTest.kt index c75b630..19a98ac 100644 --- a/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/datastore/DataStoreRoundTripTest.kt +++ b/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/datastore/DataStoreRoundTripTest.kt @@ -173,24 +173,6 @@ class DataStoreRoundTripTest { 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 fun settingsDataStore_roundTrip_mcpServers() = runTest(testDispatcher) { val ds = createDataStore("settings-mcp") diff --git a/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/repository/ConversationRepositoryImplTest.kt b/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/repository/ConversationRepositoryImplTest.kt new file mode 100644 index 0000000..2ff8585 --- /dev/null +++ b/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/repository/ConversationRepositoryImplTest.kt @@ -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(relaxed = true) + private val conversationDao = mockk(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>() + 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() + 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()) } + } + + @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() + coVerify(exactly = 1) { + conversationDao.updateTags("convo-1", capture(tagsCaptor), any()) + } + assertThat(json.decodeFromString>(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() + coVerify(exactly = 1) { + conversationDao.updateTags("convo-stale", capture(tagsCaptor), any()) + } + assertThat(json.decodeFromString>(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() + coEvery { conversationDao.upsert(capture(upsertCaptor)) } answers {} + + repository.syncFavoritesFromServer() + + assertThat(upsertCaptor.captured.conversationId).isEqualTo("convo-new") + assertThat(json.decodeFromString>(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()) } + } +} diff --git a/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/repository/TagRepositoryImplTest.kt b/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/repository/TagRepositoryImplTest.kt new file mode 100644 index 0000000..4a73f78 --- /dev/null +++ b/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/repository/TagRepositoryImplTest.kt @@ -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(relaxed = true) + private val tagDao = mockk(relaxed = true) + private val conversationRepository = mockk(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() } + } +} diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/datastore/SettingsDataStore.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/datastore/SettingsDataStore.kt index d46c12c..2f8b0ee 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/datastore/SettingsDataStore.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/datastore/SettingsDataStore.kt @@ -6,7 +6,6 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.core.stringSetPreferencesKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -69,10 +68,6 @@ class SettingsDataStore( prefs[KEY_TTS_VOICE_NAME] ?: "" } - val bookmarkedConversationIds: Flow> = dataStore.data.map { prefs -> - prefs[KEY_BOOKMARKED_CONVERSATIONS] ?: emptySet() - } - val tabletSidebarOpen: Flow = dataStore.data.map { prefs -> 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 { private val KEY_LATEX_RENDERER = stringPreferencesKey("latex_renderer") 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_PITCH = floatPreferencesKey("tts_pitch") 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_GESTURE_ENABLED = booleanPreferencesKey("tablet_sidebar_gesture_enabled") private val KEY_AUTO_SEND_AFTER_STT = booleanPreferencesKey("auto_send_after_stt") diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/db/dao/ConversationDao.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/db/dao/ConversationDao.kt index f34b45c..b13e14f 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/db/dao/ConversationDao.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/db/dao/ConversationDao.kt @@ -2,6 +2,7 @@ package com.garfiec.librechat.core.data.db.dao import androidx.room.Dao import androidx.room.Query +import androidx.room.Transaction import androidx.room.Upsert import com.garfiec.librechat.core.data.db.entity.ConversationEntity import kotlinx.coroutines.flow.Flow @@ -26,6 +27,28 @@ interface ConversationDao { @Upsert suspend fun upsertAll(conversations: List) + // 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) { + 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") suspend fun deleteById(id: String) @@ -38,6 +61,9 @@ interface ConversationDao { @Query("UPDATE conversations SET isArchived = :isArchived, updatedAt = :updatedAt WHERE conversationId = :id") 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") suspend fun deleteAll() } diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/db/dao/ConversationTagDao.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/db/dao/ConversationTagDao.kt index fab5775..99f3591 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/db/dao/ConversationTagDao.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/db/dao/ConversationTagDao.kt @@ -2,21 +2,25 @@ package com.garfiec.librechat.core.data.db.dao import androidx.room.Dao import androidx.room.Query +import androidx.room.Transaction import androidx.room.Upsert import com.garfiec.librechat.core.data.db.entity.ConversationTagEntity import kotlinx.coroutines.flow.Flow @Dao interface ConversationTagDao { - @Query("SELECT * FROM conversation_tags WHERE user = :userId ORDER BY position ASC") - fun getTagsForUser(userId: String): Flow> - - @Upsert - suspend fun upsert(tag: ConversationTagEntity) + @Query("SELECT * FROM conversation_tags ORDER BY position ASC") + fun getAllTags(): Flow> @Upsert suspend fun upsertAll(tags: List) - @Query("DELETE FROM conversation_tags WHERE id = :id") - suspend fun deleteById(id: Long) + @Query("DELETE FROM conversation_tags") + suspend fun deleteAll() + + @Transaction + suspend fun replaceAll(tags: List) { + deleteAll() + upsertAll(tags) + } } diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/mapper/TagMapper.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/mapper/TagMapper.kt new file mode 100644 index 0000000..c57e5b9 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/mapper/TagMapper.kt @@ -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.toModels(): List = 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() +} diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConversationRepository.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConversationRepository.kt index 76fb762..a63f995 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConversationRepository.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConversationRepository.kt @@ -30,4 +30,6 @@ interface ConversationRepository { suspend fun importConversation(jsonContent: String): Result suspend fun deleteAll(): Result suspend fun saveConversation(conversation: Conversation) + suspend fun updateConversationTagsLocal(id: String, tags: List) + suspend fun syncFavoritesFromServer(): Result } diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConversationRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConversationRepositoryImpl.kt index d938f69..549d04e 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConversationRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConversationRepositoryImpl.kt @@ -1,6 +1,5 @@ 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.safeApiCall 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.toModels 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.network.api.ConversationsApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer import kotlin.time.Clock class ConversationRepositoryImpl( private val conversationsApi: ConversationsApi, private val conversationDao: ConversationDao, + private val json: Json, ) : ConversationRepository { override fun observeConversations(isArchived: Boolean): Flow>> { @@ -44,19 +49,15 @@ class ConversationRepositoryImpl( sortBy = sortBy, sortDirection = sortDirection, ) - // Cache conversations locally - val entities = response.conversations.map { it.toEntity() } - conversationDao.upsertAll(entities) + conversationDao.upsertPreservingTags(response.conversations.map { it.toEntity() }) response.nextCursor } } override suspend fun getConversation(id: String): Result { - // Try local cache first val cached = conversationDao.getById(id) if (cached != null) return Result.Success(cached.toModel()) - // Fetch from network return safeApiCall { val conversation = conversationsApi.getConversation(id) conversationDao.upsert(conversation.toEntity()) @@ -75,7 +76,6 @@ class ConversationRepositoryImpl( override suspend fun generateTitle(conversationId: String): Result { return safeApiCall { val response = conversationsApi.generateTitle(conversationId) - // Update the local cache with the generated title conversationDao.updateTitle(conversationId, response.title, Clock.System.now().toEpochMilliseconds()) response.title } @@ -153,16 +153,59 @@ class ConversationRepositoryImpl( override suspend fun saveConversation(conversation: Conversation) { val id = conversation.conversationId ?: return if (id.isBlank()) return - conversationDao.upsert(conversation.toEntity()) + conversationDao.upsertPreservingTags(conversation.toEntity()) } - suspend fun refreshConversations() { - try { - val response = conversationsApi.getConversations() - val entities = response.conversations.map { it.toEntity() } - conversationDao.upsertAll(entities) - } catch (e: Exception) { - Logger.w(e) { "Failed to refresh conversations" } + override suspend fun updateConversationTagsLocal(id: String, tags: List) { + conversationDao.updateTags(id, encodeTags(tags), Clock.System.now().toEpochMilliseconds()) + } + + // Reconciles SAVED_TAG attachment between the local Room cache and server by + // paginating `GET /api/convos?tags=Saved`. Needed because upstream's + // 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 = safeApiCall { + val serverFavoriteIds = mutableSetOf() + 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 = + json.encodeToString(ListSerializer(serializer()), tags) } diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/TagRepository.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/TagRepository.kt index e530ac3..52c1419 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/TagRepository.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/TagRepository.kt @@ -2,8 +2,12 @@ package com.garfiec.librechat.core.data.repository import com.garfiec.librechat.core.common.result.Result import com.garfiec.librechat.core.model.ConversationTag +import kotlinx.coroutines.flow.Flow interface TagRepository { - suspend fun getTags(): Result> - suspend fun updateConversationTags(conversationId: String, tags: List): Result + fun observeTags(): Flow> + suspend fun refreshTags(): Result + suspend fun setConversationTags(conversationId: String, tags: List): Result + suspend fun toggleFavorite(conversationId: String, currentTags: List): Result + suspend fun clearCache() } diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/TagRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/TagRepositoryImpl.kt index 42a8f15..94b331c 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/TagRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/TagRepositoryImpl.kt @@ -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.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.SAVED_TAG import com.garfiec.librechat.core.network.api.TagsApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map class TagRepositoryImpl( private val tagsApi: TagsApi, + private val tagDao: ConversationTagDao, + private val conversationRepository: ConversationRepository, ) : TagRepository { - override suspend fun getTags(): Result> = - safeApiCall { tagsApi.getTags() } + override fun observeTags(): Flow> = + tagDao.getAllTags().map { it.toModels() } - override suspend fun updateConversationTags( + override suspend fun refreshTags(): Result = safeApiCall { + val tags = tagsApi.getTags() + tagDao.replaceAll(tags.map { it.toEntity() }) + } + + override suspend fun setConversationTags( conversationId: String, tags: List, ): Result = safeApiCall { tagsApi.updateConversationTags(conversationId, tags) + conversationRepository.updateConversationTagsLocal(conversationId, tags) + } + + override suspend fun toggleFavorite( + conversationId: String, + currentTags: List, + ): Result { + 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() } } diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/UserRepository.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/UserRepository.kt index a9b2d92..ec4a596 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/UserRepository.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/UserRepository.kt @@ -2,7 +2,6 @@ package com.garfiec.librechat.core.data.repository import com.garfiec.librechat.core.common.result.Result import com.garfiec.librechat.core.model.User -import com.garfiec.librechat.core.model.UserFavorite import com.garfiec.librechat.core.model.response.TermsResponse interface UserRepository { @@ -13,6 +12,4 @@ interface UserRepository { suspend fun resendVerification(email: String): Result suspend fun getTerms(): Result suspend fun acceptTerms(): Result - suspend fun getFavorites(): Result> - suspend fun updateFavorites(favorites: List): Result } diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/UserRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/UserRepositoryImpl.kt index 8d255ae..c3dbccb 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/UserRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/UserRepositoryImpl.kt @@ -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.safeApiCall 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.VerifyEmailRequest import com.garfiec.librechat.core.model.response.TermsResponse @@ -40,12 +39,4 @@ class UserRepositoryImpl( override suspend fun acceptTerms(): Result = safeApiCall { userApi.acceptTerms() } - - override suspend fun getFavorites(): Result> = safeApiCall { - userApi.getFavorites() - } - - override suspend fun updateFavorites(favorites: List): Result = safeApiCall { - userApi.updateFavorites(favorites) - } } diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Constants.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Constants.kt new file mode 100644 index 0000000..71568bb --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Constants.kt @@ -0,0 +1,3 @@ +package com.garfiec.librechat.core.model + +const val SAVED_TAG = "Saved" diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/User.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/User.kt index 3e7ba5a..6232195 100644 --- a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/User.kt +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/User.kt @@ -16,14 +16,6 @@ data class User( val role: String = "USER", val twoFactorEnabled: Boolean = false, val termsAccepted: Boolean = false, - val favorites: List = emptyList(), val createdAt: String? = null, val updatedAt: String? = null, ) - -@Serializable -data class UserFavorite( - val agentId: String? = null, - val model: String? = null, - val endpoint: String? = null, -) diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/CreateTagRequest.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/CreateTagRequest.kt deleted file mode 100644 index b0e35de..0000000 --- a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/CreateTagRequest.kt +++ /dev/null @@ -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, -) diff --git a/core/model/src/commonTest/kotlin/com/garfiec/librechat/core/model/UserSerializationTest.kt b/core/model/src/commonTest/kotlin/com/garfiec/librechat/core/model/UserSerializationTest.kt index 8489d90..3b6df41 100644 --- a/core/model/src/commonTest/kotlin/com/garfiec/librechat/core/model/UserSerializationTest.kt +++ b/core/model/src/commonTest/kotlin/com/garfiec/librechat/core/model/UserSerializationTest.kt @@ -30,10 +30,6 @@ class UserSerializationTest { role = "ADMIN", twoFactorEnabled = 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", updatedAt = "2026-03-28T12:00:00Z", ) @@ -55,9 +51,6 @@ class UserSerializationTest { "role": "USER", "twoFactorEnabled": false, "termsAccepted": true, - "favorites": [ - {"agentId": "a1", "endpoint": "agents"} - ], "extraField": "ignored" } """.trimIndent() @@ -65,20 +58,6 @@ class UserSerializationTest { assertEquals("user-srv", decoded.id) assertEquals("mongo-srv", decoded.mongoId) 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 @@ -89,6 +68,5 @@ class UserSerializationTest { assertEquals("USER", user.role) assertEquals(false, user.twoFactorEnabled) assertEquals(false, user.termsAccepted) - assertEquals(emptyList(), user.favorites) } } diff --git a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/ConversationsApi.kt b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/ConversationsApi.kt index 22a39ef..896a946 100644 --- a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/ConversationsApi.kt +++ b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/ConversationsApi.kt @@ -42,7 +42,9 @@ class ConversationsApi constructor( parameter("cursor", cursor) parameter("limit", limit) 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) } sortBy?.let { parameter("sortBy", it) } sortDirection?.let { parameter("sortDirection", it) } diff --git a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/TagsApi.kt b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/TagsApi.kt index 7801c9e..49ebca6 100644 --- a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/TagsApi.kt +++ b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/TagsApi.kt @@ -1,19 +1,15 @@ package com.garfiec.librechat.core.network.api 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.UpdateTagRequest import io.ktor.client.HttpClient import io.ktor.client.call.body -import io.ktor.client.request.delete import io.ktor.client.request.get -import io.ktor.client.request.post import io.ktor.client.request.put import io.ktor.client.request.setBody import io.ktor.http.path -class TagsApi constructor( +class TagsApi( private val client: HttpClient, ) { suspend fun getTags(): List = @@ -21,24 +17,6 @@ class TagsApi constructor( url { path("api/tags") } }.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) { client.put { url { path("api/tags/convo/$conversationId") } diff --git a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/UserApi.kt b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/UserApi.kt index e27ce57..4d1309d 100644 --- a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/UserApi.kt +++ b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/UserApi.kt @@ -1,12 +1,10 @@ package com.garfiec.librechat.core.network.api 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.ResendVerificationRequest import com.garfiec.librechat.core.model.request.VerifyEmailRequest 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.UserUpdateRequest import io.ktor.client.HttpClient @@ -82,17 +80,6 @@ class UserApi constructor( url { path("api/user/avatar") } }.body() - suspend fun getFavorites(): List = - client.get { - url { path("api/user/favorites") } - }.body() - - suspend fun updateFavorites(favorites: List): User = - client.post { - url { path("api/user/favorites") } - setBody(UpdateFavoritesRequest(favorites = favorites)) - }.body() - suspend fun updatePlugins(plugins: List): User = client.post { url { path("api/user/plugins") } diff --git a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/dto/UpdateFavoritesRequest.kt b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/dto/UpdateFavoritesRequest.kt deleted file mode 100644 index 2d38c3b..0000000 --- a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/dto/UpdateFavoritesRequest.kt +++ /dev/null @@ -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, -) diff --git a/feature/conversations/CLAUDE.md b/feature/conversations/CLAUDE.md index ae7a833..4106894 100644 --- a/feature/conversations/CLAUDE.md +++ b/feature/conversations/CLAUDE.md @@ -22,7 +22,7 @@ ## Tags - `TagFilterBar` displays `FilterChip` per tag (multi-select toggle) - `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 ## CRUD Actions (via `ConversationActions` component) diff --git a/feature/conversations/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/conversations/di/ConversationsModuleVerificationTest.kt b/feature/conversations/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/conversations/di/ConversationsModuleVerificationTest.kt index dcc0520..238e8c9 100644 --- a/feature/conversations/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/conversations/di/ConversationsModuleVerificationTest.kt +++ b/feature/conversations/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/conversations/di/ConversationsModuleVerificationTest.kt @@ -3,7 +3,6 @@ package com.garfiec.librechat.feature.conversations.di import android.app.Application import android.content.Context 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.ConversationRepository import com.garfiec.librechat.core.data.repository.MessageRepository @@ -27,7 +26,6 @@ class ConversationsModuleVerificationTest { SearchRepository::class, ConfigRepository::class, ServerDataStore::class, - SettingsDataStore::class, ), ) } diff --git a/feature/conversations/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModelTest.kt b/feature/conversations/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModelTest.kt index 24a5d7b..40a78a8 100644 --- a/feature/conversations/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModelTest.kt +++ b/feature/conversations/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModelTest.kt @@ -1,13 +1,13 @@ package com.garfiec.librechat.feature.conversations.viewmodel 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.SearchRepository import com.garfiec.librechat.core.data.repository.ShareRepository import com.garfiec.librechat.core.data.repository.TagRepository import com.garfiec.librechat.core.model.Conversation 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.ConversationImporter import com.google.common.truth.Truth.assertThat @@ -17,7 +17,6 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher @@ -40,7 +39,6 @@ class ConversationListViewModelTest { private val shareRepository = mockk(relaxed = true) private val conversationExporter = mockk(relaxed = true) private val conversationImporter = mockk(relaxed = true) - private val settingsDataStore = mockk(relaxed = true) private lateinit var viewModel: ConversationListViewModel @@ -61,6 +59,7 @@ class ConversationListViewModelTest { ConversationTag(tag = "work", count = 3), ConversationTag(tag = "personal", count = 2), ConversationTag(tag = "empty", count = 0), + ConversationTag(tag = SAVED_TAG, count = 5), ) @Before @@ -70,9 +69,10 @@ class ConversationListViewModelTest { every { conversationRepository.observeConversations(any()) } returns flowOf( Result.Success(testConversations), ) - every { settingsDataStore.bookmarkedConversationIds } returns MutableStateFlow(emptySet()) 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 @@ -87,7 +87,6 @@ class ConversationListViewModelTest { shareRepository = shareRepository, conversationExporter = conversationExporter, conversationImporter = conversationImporter, - settingsDataStore = settingsDataStore, ) @Test @@ -101,7 +100,7 @@ class ConversationListViewModelTest { } @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() advanceUntilIdle() @@ -433,16 +432,106 @@ class ConversationListViewModelTest { } @Test - fun `updateConversationTags reloads on success`() = runTest { - coEvery { tagRepository.updateConversationTags("convo-1", listOf("work")) } returns + fun `updateConversationTags persists new tag list when conversation was not favorited`() = runTest { + coEvery { tagRepository.setConversationTags("convo-1", listOf("work")) } returns Result.Success(Unit) viewModel = createViewModel() advanceUntilIdle() - viewModel.updateConversationTags("convo-1", listOf("work")) + val convo = Conversation(conversationId = "convo-1", tags = listOf("old")) + viewModel.updateConversationTags(convo, listOf("work")) 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()) + } } } diff --git a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationDisplayData.kt b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationDisplayData.kt index 624ce30..bf1f8b6 100644 --- a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationDisplayData.kt +++ b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationDisplayData.kt @@ -3,6 +3,7 @@ package com.garfiec.librechat.feature.conversations.components import androidx.compose.runtime.Immutable import com.garfiec.librechat.core.model.Conversation import com.garfiec.librechat.core.model.EModelEndpoint +import com.garfiec.librechat.core.model.SAVED_TAG @Immutable data class ConversationDisplayData( @@ -14,11 +15,11 @@ data class ConversationDisplayData( val isBookmarked: Boolean, ) -fun Conversation.toDisplayData(bookmarkedIds: Set) = ConversationDisplayData( +fun Conversation.toDisplayData() = ConversationDisplayData( conversationId = conversationId ?: "", title = title ?: "New Chat", endpoint = endpoint, model = model, updatedAt = updatedAt, - isBookmarked = conversationId in bookmarkedIds, + isBookmarked = SAVED_TAG in tags, ) diff --git a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/screen/ConversationListScreen.kt b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/screen/ConversationListScreen.kt index 5cdc633..e6c5de3 100644 --- a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/screen/ConversationListScreen.kt +++ b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/screen/ConversationListScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.ErrorBanner import com.garfiec.librechat.core.ui.components.LoadingIndicator @@ -326,12 +327,10 @@ fun ConversationListScreen( ConversationActions( conversation = selectedConversation!!, onDismiss = { selectedConversation = null }, - isBookmarked = selectedConversation?.conversationId?.let { - viewModel.isBookmarked(it) - } ?: false, + isBookmarked = SAVED_TAG in (selectedConversation?.tags ?: emptyList()), onBookmarkToggle = { - selectedConversation?.conversationId?.let { id -> - viewModel.toggleBookmark(id) + selectedConversation?.let { convo -> + viewModel.toggleFavorite(convo) } selectedConversation = null }, @@ -383,10 +382,10 @@ fun ConversationListScreen( if (showTagPicker && tagPickerConversation != null) { TagPicker( availableTags = uiState.tags, - currentTags = tagPickerConversation?.tags ?: emptyList(), + currentTags = tagPickerConversation?.tags?.filterNot { it == SAVED_TAG } ?: emptyList(), onTagsChange = { newTags -> - tagPickerConversation?.conversationId?.let { id -> - viewModel.updateConversationTags(id, newTags) + tagPickerConversation?.let { convo -> + viewModel.updateConversationTags(convo, newTags) } }, onDismiss = { diff --git a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModel.kt b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModel.kt index d746966..320d32d 100644 --- a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModel.kt +++ b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModel.kt @@ -7,19 +7,20 @@ 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.datastore.SettingsDataStore import com.garfiec.librechat.core.data.repository.ConversationRepository import com.garfiec.librechat.core.data.repository.SearchRepository import com.garfiec.librechat.core.data.repository.ShareRepository import com.garfiec.librechat.core.data.repository.TagRepository import com.garfiec.librechat.core.model.Conversation 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.toDisplayData import com.garfiec.librechat.feature.conversations.export.ConversationExporter import com.garfiec.librechat.feature.conversations.export.ConversationImporter import com.garfiec.librechat.feature.conversations.export.ExportFormat import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -59,7 +60,6 @@ class ConversationListViewModel( private val shareRepository: ShareRepository, private val conversationExporter: ConversationExporter, private val conversationImporter: ConversationImporter, - private val settingsDataStore: SettingsDataStore, ) : ViewModel() { private val _uiState = MutableStateFlow(ConversationListUiState()) @@ -70,14 +70,12 @@ class ConversationListViewModel( /** Raw conversations kept for internal lookups (action sheets, etc.). */ private var conversations: List = emptyList() - private var bookmarkedIds: Set = emptySet() private var searchJob: Job? = null init { loadConversations() observeConversations() - observeBookmarks() - loadTags() + observeTags() } private fun observeConversations() { @@ -104,17 +102,18 @@ class ConversationListViewModel( } } - private fun observeBookmarks() { + private fun observeTags() { viewModelScope.launch { - settingsDataStore.bookmarkedConversationIds.collect { ids -> - bookmarkedIds = ids - recomputeGroupedConversations() + tagRepository.observeTags().collect { tags -> + _uiState.value = _uiState.value.copy( + tags = tags.filter { it.count > 0 && it.tag != SAVED_TAG }, + ) } } } private fun recomputeGroupedConversations() { - val grouped = groupConversationsByDate(conversations, bookmarkedIds) + val grouped = groupConversationsByDate(conversations) _uiState.value = _uiState.value.copy( groupedConversations = grouped, conversationCount = conversations.size, @@ -124,12 +123,20 @@ class ConversationListViewModel( fun getConversation(conversationId: String): Conversation? = conversations.firstOrNull { it.conversationId == conversationId } - fun isBookmarked(conversationId: String): Boolean = - conversationId in bookmarkedIds - - fun toggleBookmark(conversationId: String) { + fun toggleFavorite(conversation: Conversation) { + val id = conversation.conversationId ?: return 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 { _uiState.value = _uiState.value.copy(isRefreshing = true) val activeTags = _uiState.value.selectedTags.toList().ifEmpty { null } - when (val result = conversationRepository.loadNextPage(null, tags = activeTags)) { - is Result.Success -> { - _uiState.value = _uiState.value.copy( - isRefreshing = false, - nextCursor = result.data, - hasMore = result.data != null, - ) + coroutineScope { + launch { + when (val result = conversationRepository.loadNextPage(null, tags = activeTags)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + 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 -> { - _uiState.value = _uiState.value.copy( - 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 */ } + launch { tagRepository.refreshTags() } + launch { conversationRepository.syncFavoritesFromServer() } } + _uiState.value = _uiState.value.copy(isRefreshing = false) } } @@ -265,12 +257,17 @@ class ConversationListViewModel( } } - fun updateConversationTags(conversationId: String, tags: List) { + fun updateConversationTags(conversation: Conversation, userTags: List) { + 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 { - when (tagRepository.updateConversationTags(conversationId, tags)) { + when (tagRepository.setConversationTags(id, finalTags)) { is Result.Success -> { - loadTags() - refresh() + // Room Flow already emits the updated conversation via + // observeConversations; we only need fresh tag counts. + tagRepository.refreshTags() } is Result.Error -> { _events.emit(ConversationListEvent.ShowError("Failed to update tags")) @@ -434,7 +431,6 @@ class ConversationListViewModel( companion object { private fun groupConversationsByDate( conversations: List, - bookmarkedIds: Set, ): List>> { if (conversations.isEmpty()) return emptyList() return conversations @@ -445,7 +441,7 @@ class ConversationListViewModel( ?: "Unknown" } .map { (group, convos) -> - group to convos.map { it.toDisplayData(bookmarkedIds) } + group to convos.map { it.toDisplayData() } } } } diff --git a/feature/settings/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/settings/viewmodel/SettingsViewModelTest.kt b/feature/settings/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/settings/viewmodel/SettingsViewModelTest.kt index a08a215..cd0da2c 100644 --- a/feature/settings/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/settings/viewmodel/SettingsViewModelTest.kt +++ b/feature/settings/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/settings/viewmodel/SettingsViewModelTest.kt @@ -100,7 +100,6 @@ class SettingsViewModelTest { every { settingsDataStore.showAvatars } returns MutableStateFlow(true) every { settingsDataStore.showBubbles } returns MutableStateFlow(false) every { settingsDataStore.latexRenderer } returns MutableStateFlow(LatexRenderer.KATEX) - every { settingsDataStore.bookmarkedConversationIds } returns MutableStateFlow(emptySet()) // Setup default API responses coEvery { userRepository.getUser() } returns Result.Success(testUser) diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/ConversationListStateHolder.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/ConversationListStateHolder.kt index c2918a2..db08088 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/ConversationListStateHolder.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/ConversationListStateHolder.kt @@ -8,6 +8,7 @@ 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.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -108,7 +109,10 @@ class ConversationListStateHolder( scope.launch { nextCursor = null _hasMore.value = true - loadPage(cursor = null) + coroutineScope { + launch { loadPage(cursor = null) } + launch { conversationRepository.syncFavoritesFromServer() } + } _isRefreshing.value = false } } diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerContent.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerContent.kt index 83480a7..12b3647 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerContent.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerContent.kt @@ -95,7 +95,7 @@ fun DrawerContent( onSettingsClick = onSettingsClick, onAgentsClick = onAgentsClick, onFilesClick = onFilesClick, - onToggleFavorite = viewModel::toggleFavorite, + onToggleFavorite = { data -> viewModel.toggleFavorite(data.conversationId, data.tags) }, onRefresh = viewModel::refreshConversations, onLoadMore = viewModel::loadMoreConversations, modifier = modifier, @@ -113,7 +113,7 @@ fun DrawerContent( onAgentsClick: () -> Unit, onFilesClick: () -> Unit, modifier: Modifier = Modifier, - onToggleFavorite: (String) -> Unit = {}, + onToggleFavorite: (DrawerConversationDisplayData) -> Unit = {}, onRefresh: () -> Unit = {}, onLoadMore: () -> Unit = {}, ) { @@ -257,7 +257,7 @@ fun DrawerContent( DrawerConversationItem( data = data, onClick = { onConversationClick(data.conversationId) }, - onToggleFavorite = { onToggleFavorite(data.conversationId) }, + onToggleFavorite = { onToggleFavorite(data) }, ) } @@ -303,7 +303,7 @@ fun DrawerContent( DrawerConversationItem( data = data, onClick = { onConversationClick(data.conversationId) }, - onToggleFavorite = { onToggleFavorite(data.conversationId) }, + onToggleFavorite = { onToggleFavorite(data) }, ) } } diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerDisplayMapping.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerDisplayMapping.kt index 41ecbf1..6d1463a 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerDisplayMapping.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerDisplayMapping.kt @@ -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.toInstantOrNull import com.garfiec.librechat.core.model.Conversation +import com.garfiec.librechat.core.model.SAVED_TAG import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock @@ -11,14 +12,12 @@ import kotlin.time.Instant internal data class DrawerDataSnapshot( val grouped: List>>, val activeId: String?, - val favIds: Set, val favConvos: List, val query: String, ) internal fun Conversation.toDrawerDisplayData( activeConversationId: String?, - favoriteIds: Set, ): DrawerConversationDisplayData { val convId = conversationId ?: "" return DrawerConversationDisplayData( @@ -28,7 +27,8 @@ internal fun Conversation.toDrawerDisplayData( endpoint = endpoint, relativeTime = updatedAt?.toInstantOrNull()?.toRelativeTimeString() ?: "", isActive = convId == activeConversationId, - isFavorite = convId in favoriteIds, + isFavorite = SAVED_TAG in tags, + tags = tags, ) } diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerUiModels.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerUiModels.kt index d7779e7..90feaad 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerUiModels.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerUiModels.kt @@ -16,6 +16,7 @@ data class DrawerConversationDisplayData( val relativeTime: String, val isActive: Boolean, val isFavorite: Boolean, + val tags: List, ) /** diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/FavoritesStateHolder.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/FavoritesStateHolder.kt deleted file mode 100644 index 43c256c..0000000 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/FavoritesStateHolder.kt +++ /dev/null @@ -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>, - private val scope: CoroutineScope, -) { - - private val _favorites = MutableStateFlow>(emptySet()) - val favorites: StateFlow> = _favorites.asStateFlow() - - private val _favoriteConversations = MutableStateFlow>(emptyList()) - val favoriteConversations: StateFlow> = _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() - } -} diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavHostViewModel.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavHostViewModel.kt index 3384fe7..ce8a453 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavHostViewModel.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavHostViewModel.kt @@ -4,12 +4,16 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger 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.repository.AuthRepository import com.garfiec.librechat.core.data.repository.BannerRepository import com.garfiec.librechat.core.data.repository.ConfigRepository 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.Conversation +import com.garfiec.librechat.core.model.SAVED_TAG import com.garfiec.librechat.core.network.client.TokenManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -17,6 +21,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -24,7 +29,8 @@ class NavHostViewModel( private val authRepository: AuthRepository, bannerRepository: BannerRepository, configRepository: ConfigRepository, - conversationRepository: ConversationRepository, + private val conversationRepository: ConversationRepository, + private val tagRepository: TagRepository, private val tokenManager: TokenManager, private val settingsDataStore: SettingsDataStore, ) : ViewModel() { @@ -32,8 +38,11 @@ class NavHostViewModel( private val bannerStateHolder = BannerStateHolder(bannerRepository, viewModelScope) private val versionCheckStateHolder = VersionCheckStateHolder(configRepository, settingsDataStore, viewModelScope) private val conversationListStateHolder = ConversationListStateHolder(conversationRepository, viewModelScope) - private val favoritesStateHolder = - FavoritesStateHolder(settingsDataStore, conversationListStateHolder.recentConversations, viewModelScope) + + private val favoriteConversations: StateFlow> = + conversationListStateHolder.recentConversations + .map { list -> list.filter { SAVED_TAG in it.tags } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) private val _isLoggedIn = MutableStateFlow(tokenManager.isAuthenticated) val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() @@ -73,11 +82,10 @@ class NavHostViewModel( combine( conversationListStateHolder.groupedConversations, conversationListStateHolder.activeConversationId, - favoritesStateHolder.favorites, - favoritesStateHolder.favoriteConversations, + favoriteConversations, conversationListStateHolder.searchQuery, - ) { grouped, activeId, favIds, favConvos, query -> - DrawerDataSnapshot(grouped, activeId, favIds, favConvos, query) + ) { grouped, activeId, favConvos, query -> + DrawerDataSnapshot(grouped, activeId, favConvos, query) }, combine( conversationListStateHolder.isRefreshing, @@ -89,9 +97,9 @@ class NavHostViewModel( ) { data, (refreshing, loadingMore, hasMore) -> DrawerUiState( 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, isRefreshing = refreshing, isLoadingMore = loadingMore, @@ -106,6 +114,8 @@ class NavHostViewModel( _isLoggedIn.value = loggedIn if (loggedIn) { versionCheckStateHolder.checkBackendVersion() + launch { tagRepository.refreshTags() } + launch { conversationRepository.syncFavoritesFromServer() } } } catch (e: Exception) { Logger.w(e) { "Failed to check auth state on init" } @@ -121,12 +131,17 @@ class NavHostViewModel( fun onAuthComplete() { _isLoggedIn.value = true conversationListStateHolder.refreshConversations() + viewModelScope.launch { + launch { tagRepository.refreshTags() } + launch { conversationRepository.syncFavoritesFromServer() } + } bannerStateHolder.fetchBanners() versionCheckStateHolder.checkBackendVersion() } fun refreshConversations() { conversationListStateHolder.refreshConversations() + viewModelScope.launch { tagRepository.refreshTags() } } fun setActiveConversation(conversationId: String?) { @@ -137,8 +152,13 @@ class NavHostViewModel( conversationListStateHolder.onSearchQueryChanged(query) } - fun toggleFavorite(conversationId: String) { - favoritesStateHolder.toggleFavorite(conversationId) + fun toggleFavorite(conversationId: String, currentTags: List) { + 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) { @@ -164,7 +184,7 @@ class NavHostViewModel( authRepository.logout() _isLoggedIn.value = false conversationListStateHolder.reset() - favoritesStateHolder.reset() + tagRepository.clearCache() _sidebarMode.value = SidebarMode.Conversations _selectedSettingsCategory.value = null }