diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryFragment.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryFragment.kt index 403367e8a52b..8cba71f26ea5 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryFragment.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryFragment.kt @@ -29,6 +29,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.common.ui.DuckDuckGoFragment import com.duckduckgo.common.ui.menu.PopupMenu +import com.duckduckgo.common.ui.view.PopupMenuItemView import com.duckduckgo.common.ui.view.SearchBar import com.duckduckgo.common.ui.view.gone import com.duckduckgo.common.ui.view.show @@ -124,6 +125,11 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { .flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED) .onEach(::onNavigationEvent) .launchIn(viewLifecycleOwner.lifecycleScope) + + viewModel.messageEvents + .flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED) + .onEach(::onMessageEvent) + .launchIn(viewLifecycleOwner.lifecycleScope) } private fun onNavigationEvent(event: ChatHistoryViewModel.NavigationEvent) { @@ -132,6 +138,12 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { } } + private fun onMessageEvent(event: ChatHistoryViewModel.MessageEvent) { + when (event) { + is ChatHistoryViewModel.MessageEvent.PinToggled -> showPinToggledSnackbar(event) + } + } + private fun openRenameScreen(chatId: String, currentTitle: String) { parentFragmentManager.beginTransaction() .replace(R.id.chatHistoryFragmentContainer, RenameChatFragment.newInstance(chatId, currentTitle)) @@ -139,6 +151,19 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { .commit() } + private fun showPinToggledSnackbar(event: ChatHistoryViewModel.MessageEvent.PinToggled) { + val messageRes = if (event.wasPinned) { + R.string.duck_ai_chat_history_unpin_snackbar + } else { + R.string.duck_ai_chat_history_pin_snackbar + } + Snackbar.make(binding.root, messageRes, Snackbar.LENGTH_LONG) + .setAction(R.string.duck_ai_chat_history_undo) { + viewModel.onUndoTogglePin(event.chatId, restorePinned = event.wasPinned) + } + .show() + } + private fun render(state: ChatHistoryUiState) { logcat { "ChatHistory: render ${state::class.simpleName}" } when (state) { @@ -286,7 +311,10 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { private fun showRowPopup(item: ChatHistoryItem, anchor: View) { val popup = PopupMenu(layoutInflater, R.layout.popup_chat_history_row) val view = popup.contentView - popup.onMenuItemClicked(view.findViewById(R.id.pin)) { showComingSoonSnackbar() } + val pinAction = view.findViewById(R.id.pin) + val pinLabel = if (item.pinned) R.string.duck_ai_chat_history_action_unpin else R.string.duck_ai_chat_history_action_pin + pinAction.setPrimaryText(getString(pinLabel)) + popup.onMenuItemClicked(pinAction) { viewModel.onTogglePin(item.chatId) } popup.onMenuItemClicked(view.findViewById(R.id.rename)) { viewModel.onRenameRequested(item.chatId, item.displayTitle) } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryRepository.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryRepository.kt index 4226abdbfc9d..834ff150143d 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryRepository.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryRepository.kt @@ -36,6 +36,7 @@ interface ChatHistoryRepository { suspend fun deleteChat(chatId: String) suspend fun deleteAllChats() suspend fun renameChat(chatId: String, newTitle: String): Boolean + suspend fun setPinned(chatId: String, pinned: Boolean) } @ContributesBinding(AppScope::class) @@ -63,6 +64,12 @@ class RealChatHistoryRepository @Inject constructor( override suspend fun renameChat(chatId: String, newTitle: String): Boolean = withContext(dispatchers.io()) { chatStore.renameChat(chatId, newTitle) } + override suspend fun setPinned(chatId: String, pinned: Boolean) { + withContext(dispatchers.io()) { + if (pinned) chatStore.pinChat(chatId) else chatStore.unpinChat(chatId) + } + } + private fun toChatHistoryItem(chat: DuckAiChat): ChatHistoryItem = ChatHistoryItem( chatId = chat.chatId, displayTitle = chat.title.takeIf { it.isNotBlank() && it != UPSTREAM_UNTITLED } ?: fallbackTitle, diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModel.kt index 62fbd178cf03..98adc04162ae 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModel.kt @@ -57,6 +57,9 @@ class ChatHistoryViewModel @Inject constructor( private val navigationChannel = Channel(capacity = Channel.BUFFERED, onBufferOverflow = BufferOverflow.DROP_OLDEST) val navigationEvents: Flow = navigationChannel.receiveAsFlow() + private val messageChannel = Channel(capacity = Channel.BUFFERED, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val messageEvents: Flow = messageChannel.receiveAsFlow() + /** Cached snapshot so non-suspend action methods can read Recent without re-subscribing. */ private var latestItems: List = emptyList() @@ -134,6 +137,17 @@ class ChatHistoryViewModel @Inject constructor( navigationChannel.trySend(NavigationEvent.OpenRename(chatId = chatId, currentTitle = currentTitle)) } + fun onTogglePin(chatId: String) { + val current = latestItems.firstOrNull { it.chatId == chatId } ?: return + val wasPinned = current.pinned + appScope.launch { chatHistoryRepository.setPinned(chatId, !wasPinned) } + messageChannel.trySend(MessageEvent.PinToggled(chatId = chatId, wasPinned = wasPinned)) + } + + fun onUndoTogglePin(chatId: String, restorePinned: Boolean) { + appScope.launch { chatHistoryRepository.setPinned(chatId, restorePinned) } + } + private fun dispatchSelectedClear(chatIds: Set) { if (chatIds.isEmpty()) return if (!duckAiFeatureState.showClearDuckAIChatHistory.value) return @@ -262,6 +276,10 @@ class ChatHistoryViewModel @Inject constructor( data class OpenRename(val chatId: String, val currentTitle: String) : NavigationEvent } + sealed interface MessageEvent { + data class PinToggled(val chatId: String, val wasPinned: Boolean) : MessageEvent + } + private companion object { const val STOP_TIMEOUT_MILLIS = 5_000L } diff --git a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml index c13d85162ec7..a6610bcd4262 100644 --- a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml +++ b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml @@ -107,4 +107,7 @@ Save Save chat title Couldn\'t rename chat + Chat pinned + Chat unpinned + Undo diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModelTest.kt index ae28ffaa1198..bbb477681a20 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModelTest.kt @@ -818,6 +818,106 @@ class ChatHistoryViewModelTest { } } + @Test + fun `onTogglePin flips pinned to true when row was unpinned`() = coroutineRule.testScope.runTest { + source.value = listOf(item("a", pinned = false)) + + viewModel.uiState.test { + awaitInitialLoaded() + viewModel.onTogglePin("a") + val updated = awaitItem() as Loaded + assertEquals(listOf("a"), updated.pinned.map { it.chatId }) + assertEquals(emptyList(), updated.recent.map { it.chatId }) + } + assertEquals(listOf("a" to true), repository.pinnedChats) + } + + @Test + fun `onTogglePin flips pinned to false when row was pinned`() = coroutineRule.testScope.runTest { + source.value = listOf(item("a", pinned = true)) + + viewModel.uiState.test { + awaitInitialLoaded() + viewModel.onTogglePin("a") + val updated = awaitItem() as Loaded + assertEquals(emptyList(), updated.pinned.map { it.chatId }) + assertEquals(listOf("a"), updated.recent.map { it.chatId }) + } + assertEquals(listOf("a" to false), repository.pinnedChats) + } + + @Test + fun `onTogglePin is a no-op when chatId is unknown`() = coroutineRule.testScope.runTest { + source.value = listOf(item("a", pinned = false)) + + viewModel.uiState.test { + awaitInitialLoaded() + viewModel.onTogglePin("missing") + expectNoEvents() + } + assertTrue(repository.pinnedChats.isEmpty()) + } + + @Test + fun `onTogglePin emits PinToggled message event with previous pinned state false`() = coroutineRule.testScope.runTest { + source.value = listOf(item("a", pinned = false)) + + viewModel.messageEvents.test { + // Drain the initial Loaded state so latestItems is primed. + viewModel.uiState.test { awaitInitialLoaded() } + viewModel.onTogglePin("a") + + val event = awaitItem() + assertTrue(event is ChatHistoryViewModel.MessageEvent.PinToggled) + event as ChatHistoryViewModel.MessageEvent.PinToggled + assertEquals("a", event.chatId) + assertEquals(false, event.wasPinned) + } + } + + @Test + fun `onTogglePin emits PinToggled message event with previous pinned state true`() = coroutineRule.testScope.runTest { + source.value = listOf(item("a", pinned = true)) + + viewModel.messageEvents.test { + viewModel.uiState.test { awaitInitialLoaded() } + viewModel.onTogglePin("a") + + val event = awaitItem() + assertTrue(event is ChatHistoryViewModel.MessageEvent.PinToggled) + event as ChatHistoryViewModel.MessageEvent.PinToggled + assertEquals("a", event.chatId) + assertEquals(true, event.wasPinned) + } + } + + @Test + fun `onTogglePin emits no message event when chatId is unknown`() = coroutineRule.testScope.runTest { + source.value = listOf(item("a", pinned = false)) + + viewModel.messageEvents.test { + viewModel.uiState.test { awaitInitialLoaded() } + viewModel.onTogglePin("missing") + expectNoEvents() + } + } + + @Test + fun `onUndoTogglePin restores the requested pinned state via the repository`() = coroutineRule.testScope.runTest { + source.value = listOf(item("a", pinned = true)) + + viewModel.uiState.test { + awaitInitialLoaded() + viewModel.onTogglePin("a") // a → unpinned + awaitItem() // Loaded after the unpin write + viewModel.onUndoTogglePin("a", restorePinned = true) + val restored = awaitItem() as Loaded + assertEquals(listOf("a"), restored.pinned.map { it.chatId }) + assertEquals(emptyList(), restored.recent.map { it.chatId }) + } + assertEquals(listOf("a" to false, "a" to true), repository.pinnedChats) + } + /** * `stateIn(WhileSubscribed)` does not guarantee subscribers observe the `Loading` initial * value — the upstream may emit before the StateFlow can replay it. Tolerate both orderings. @@ -851,6 +951,7 @@ private class FakeChatHistoryRepository( ) : ChatHistoryRepository { val deletedChatIds: MutableList = mutableListOf() val renamedChats: MutableList> = mutableListOf() + val pinnedChats: MutableList> = mutableListOf() var deleteAllChatsCalled: Boolean = false private set @@ -870,6 +971,11 @@ private class FakeChatHistoryRepository( renamedChats += chatId to newTitle return true } + + override suspend fun setPinned(chatId: String, pinned: Boolean) { + pinnedChats += chatId to pinned + source.value = source.value.map { if (it.chatId == chatId) it.copy(pinned = pinned) else it } + } } private class RecordingDataClearingTrigger : DataClearingTrigger { diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/history/RenameChatViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/history/RenameChatViewModelTest.kt index 56986dba4d9a..92796310b243 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/history/RenameChatViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/history/RenameChatViewModelTest.kt @@ -81,4 +81,6 @@ private class RecordingRenameRepository : ChatHistoryRepository { renames += chatId to newTitle return nextResult } + + override suspend fun setPinned(chatId: String, pinned: Boolean) = Unit } diff --git a/duckchat/duckchat-store/src/main/java/com/duckduckgo/duckchat/store/impl/RealDuckAiChatStore.kt b/duckchat/duckchat-store/src/main/java/com/duckduckgo/duckchat/store/impl/RealDuckAiChatStore.kt index c5436ab5f268..5523d26183ea 100644 --- a/duckchat/duckchat-store/src/main/java/com/duckduckgo/duckchat/store/impl/RealDuckAiChatStore.kt +++ b/duckchat/duckchat-store/src/main/java/com/duckduckgo/duckchat/store/impl/RealDuckAiChatStore.kt @@ -66,6 +66,12 @@ interface DuckAiChatStore { /** Renames [chatId] in the FE-owned JSON blob. Returns true if the chat existed and was updated, false otherwise. */ suspend fun renameChat(chatId: String, newTitle: String): Boolean + + /** Pins [chatId]. Silent no-op if the chat is not found or the stored JSON is malformed. */ + suspend fun pinChat(chatId: String) + + /** Unpins [chatId]. Silent no-op if the chat is not found or the stored JSON is malformed. */ + suspend fun unpinChat(chatId: String) } @SingleInstanceIn(AppScope::class) @@ -143,6 +149,21 @@ class RealDuckAiChatStore @Inject constructor( true } + override suspend fun pinChat(chatId: String) = setPinned(chatId, pinned = true) + + override suspend fun unpinChat(chatId: String) = setPinned(chatId, pinned = false) + + private suspend fun setPinned(chatId: String, pinned: Boolean) { + withContext(dispatchers.io()) { + logcat { "DuckAI: RealDuckAiChatStore.setPinned($chatId, $pinned)" } + val entity = chatsDao.getById(chatId) ?: return@withContext + val updatedJson = runCatching { + JSONObject(entity.data).put("pinned", pinned).toString() + }.getOrNull() ?: return@withContext + chatsDao.upsert(entity.copy(data = updatedJson)) + } + } + private fun DuckAiBridgeChatEntity.toDuckAiChat(): DuckAiChat? = runCatching { val json = JSONObject(data) val chatId = json.optString("chatId").takeIf { it.isNotEmpty() } ?: return@runCatching null diff --git a/duckchat/duckchat-store/src/test/kotlin/com/duckduckgo/duckchat/store/impl/RealDuckAiChatStoreTest.kt b/duckchat/duckchat-store/src/test/kotlin/com/duckduckgo/duckchat/store/impl/RealDuckAiChatStoreTest.kt index 3e4db9f819dc..674d2ebbe6dd 100644 --- a/duckchat/duckchat-store/src/test/kotlin/com/duckduckgo/duckchat/store/impl/RealDuckAiChatStoreTest.kt +++ b/duckchat/duckchat-store/src/test/kotlin/com/duckduckgo/duckchat/store/impl/RealDuckAiChatStoreTest.kt @@ -336,4 +336,60 @@ class RealDuckAiChatStoreTest { // traversal file should not be deleted — it's outside filesDir verify(chatsDao).deleteAll() } + + // --- pinChat / unpinChat --- + + @Test + fun `pinChat is a no-op when chat not found`() = runTest { + whenever(chatsDao.getById("missing")).thenReturn(null) + + store.pinChat("missing") + + verify(chatsDao, never()).upsert(any()) + } + + @Test + fun `pinChat sets pinned to true and preserves other JSON fields`() = runTest { + val originalJson = """ + {"chatId":"abc","title":"Old","model":"gpt-5-mini","lastEdit":"2026-04-01T21:31:54.260Z","pinned":false,"fileRefs":["uuid1"],"messages":[{"role":"user","text":"hi"}]} + """.trimIndent() + whenever(chatsDao.getById("abc")).thenReturn(DuckAiBridgeChatEntity("abc", originalJson)) + + store.pinChat("abc") + + val entityCaptor = argumentCaptor() + verify(chatsDao).upsert(entityCaptor.capture()) + val json = JSONObject(entityCaptor.firstValue.data) + assertTrue(json.getBoolean("pinned")) + assertEquals("Old", json.getString("title")) + assertEquals("abc", json.getString("chatId")) + assertEquals("gpt-5-mini", json.getString("model")) + assertEquals("2026-04-01T21:31:54.260Z", json.getString("lastEdit")) + assertEquals("uuid1", json.getJSONArray("fileRefs").getString(0)) + assertEquals("hi", json.getJSONArray("messages").getJSONObject(0).getString("text")) + } + + @Test + fun `unpinChat sets pinned to false`() = runTest { + val originalJson = """ + {"chatId":"abc","title":"Old","model":"gpt-5-mini","lastEdit":"2026-04-01T21:31:54.260Z","pinned":true} + """.trimIndent() + whenever(chatsDao.getById("abc")).thenReturn(DuckAiBridgeChatEntity("abc", originalJson)) + + store.unpinChat("abc") + + val entityCaptor = argumentCaptor() + verify(chatsDao).upsert(entityCaptor.capture()) + val json = JSONObject(entityCaptor.firstValue.data) + assertFalse(json.getBoolean("pinned")) + } + + @Test + fun `pinChat is a no-op when stored JSON is malformed`() = runTest { + whenever(chatsDao.getById("abc")).thenReturn(DuckAiBridgeChatEntity("abc", "not a json")) + + store.pinChat("abc") + + verify(chatsDao, never()).upsert(any()) + } }