diff --git a/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt b/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt index c4bcd82b680f..d4704ee4e52b 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt @@ -95,10 +95,9 @@ class DataClearing @Inject constructor( val clearDataResult = clearDataAction.clearDataForSpecificDomains(visitedSites) val tabUrl = tabRepository.getTab(tabId)?.url - clearDuckAiChatIfNeeded(tabUrl) - clearContextualChatDataIfNeeded(tabId) - navigationHistory.removeHistoryForTab(tabId) - + // Reset this tab's URL before dispatching the chat clear: the tabs-cleanup plugin matches + // tabs by chatID, and we don't want this tab caught by that match — it stays open with a + // new chat URL. Other tabs at the same chatID (duplicates) do get closed, which is desired. if (replaceCurrentTab) { val url = getNewTabUrl(tabUrl) tabOperations.replaceTabWithNewTab(tabId, url) @@ -106,6 +105,10 @@ class DataClearing @Inject constructor( tabRepository.deleteTabs(listOf(tabId)) } + clearDuckAiChatIfNeeded(tabUrl) + clearContextualChatDataIfNeeded(tabId) + navigationHistory.removeHistoryForTab(tabId) + logcat { "Single tab clear completed for tab: $tabId" } return clearDataResult } @@ -233,6 +236,12 @@ class DataClearing @Inject constructor( return fireDataStore.getAutomaticClearOptions().isNotEmpty() } + override suspend fun clearSelectedDuckAiChats(chatUrls: Set) { + if (chatUrls.isEmpty()) return + if (!duckAiFeatureState.showClearDuckAIChatHistory.value) return + dataClearingTrigger.clearData(setOf(ClearableData.DuckChats.Selected(chatUrls))) + } + /** * Performs granular data clearing based on the provided options * @return true if process needs to be restarted diff --git a/app/src/main/java/com/duckduckgo/app/fire/DuckAiTabsCleanupPlugin.kt b/app/src/main/java/com/duckduckgo/app/fire/DuckAiTabsCleanupPlugin.kt new file mode 100644 index 000000000000..8413d232e3d8 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/fire/DuckAiTabsCleanupPlugin.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.fire + +import android.net.Uri +import androidx.core.net.toUri +import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.dataclearing.api.plugin.ClearableData +import com.duckduckgo.dataclearing.api.plugin.DataClearingPlugin +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckchat.api.DuckChat +import com.squareup.anvil.annotations.ContributesMultibinding +import logcat.logcat +import javax.inject.Inject + +/** Closes Duck.ai tabs left pointing at cleared chat URLs. Never touches non-Duck.ai tabs. */ +@ContributesMultibinding(AppScope::class) +class DuckAiTabsCleanupPlugin @Inject constructor( + private val tabRepository: TabRepository, + private val duckChat: DuckChat, +) : DataClearingPlugin { + + override suspend fun onClearData(types: Set) { + types.forEach { type -> + when (type) { + is ClearableData.DuckChats.All -> closeAllDuckAiTabs() + is ClearableData.DuckChats.Selected -> closeTabsMatching(type.chatUrls) + else -> { /* not handled by this plugin */ } + } + } + } + + private suspend fun closeAllDuckAiTabs() { + val ids = tabRepository.getTabs() + .filter { tab -> tab.url?.toUri()?.let(duckChat::isDuckChatUrl) == true } + .map { it.tabId } + if (ids.isNotEmpty()) { + logcat { "Closing ${ids.size} open Duck.ai tab(s) after chat clear" } + tabRepository.deleteTabs(ids) + } + } + + /** + * Match by `chatID` query param rather than full URL equality — tabs drift (server redirects, + * extra query params accumulated during the session) so multiple tabs of the same chat would + * otherwise miss the match. + */ + private suspend fun closeTabsMatching(chatUrls: Set) { + if (chatUrls.isEmpty()) return + val targetChatIds = chatUrls.mapNotNullTo(mutableSetOf()) { it.toUri().chatIdOrNull() } + if (targetChatIds.isEmpty()) return + val ids = tabRepository.getTabs() + .filter { tab -> tab.url?.toUri()?.chatIdOrNull() in targetChatIds } + .map { it.tabId } + if (ids.isNotEmpty()) { + logcat { "Closing ${ids.size} Duck.ai tab(s) matching the cleared subset" } + tabRepository.deleteTabs(ids) + } + } + + private fun Uri.chatIdOrNull(): String? { + if (!duckChat.isDuckChatUrl(this)) return null + return getQueryParameter("chatID")?.takeIf { it.isNotBlank() } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/fire/ManualDataClearing.kt b/app/src/main/java/com/duckduckgo/app/fire/ManualDataClearing.kt index d893bb5c83f7..046a64ab76c9 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/ManualDataClearing.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/ManualDataClearing.kt @@ -29,6 +29,9 @@ interface ManualDataClearing { */ suspend fun clearDataUsingManualFireOptions(shouldRestartIfRequired: Boolean = false, wasAppUsedSinceLastClear: Boolean = false) + /** Deletes only the chats addressed by [chatUrls] and closes any browser tabs pointing at them. */ + suspend fun clearSelectedDuckAiChats(chatUrls: Set) + /** * Clears all data associated with tab: * site browsing data (via WebStorageCompat), tab-specific history, diff --git a/app/src/main/java/com/duckduckgo/app/global/view/SingleTabFireDialog.kt b/app/src/main/java/com/duckduckgo/app/global/view/SingleTabFireDialog.kt index 03dd48a255e1..eaca44d74e34 100644 --- a/app/src/main/java/com/duckduckgo/app/global/view/SingleTabFireDialog.kt +++ b/app/src/main/java/com/duckduckgo/app/global/view/SingleTabFireDialog.kt @@ -72,11 +72,13 @@ private const val BOTTOM_SHEET_MAX_WIDTH_DP = 640 private const val NO_MAX_WIDTH = -1 private const val ARG_ORIGIN = "origin" private const val ARG_TAB_ID = "tabId" +private const val ARG_SELECTED_CHAT_URLS = "selectedChatUrls" internal const val ORIGIN_BROWSER = "Browser" internal const val ORIGIN_SETTINGS = "Settings" internal const val ORIGIN_TAB_SWITCHER = "TabSwitcher" internal const val ORIGIN_DUCK_AI_CONTEXTUAL_CHAT = "DuckAiContextualChat" internal const val ORIGIN_HATCH = "Hatch" +internal const val ORIGIN_CHAT_HISTORY = "ChatHistory" @InjectWith(FragmentScope::class) class SingleTabFireDialog : BottomSheetDialogFragment(), FireDialog { @@ -174,11 +176,11 @@ class SingleTabFireDialog : BottomSheetDialogFragment(), FireDialog { private fun setupLayout() { binding.deleteAllPrimaryButton.setOnClickListener { hideDialog() - viewModel.onDeleteAllClicked() + dispatchDeleteAll() } binding.deleteAllSecondaryButton.setOnClickListener { hideDialog() - viewModel.onDeleteAllClicked() + dispatchDeleteAll() } binding.deleteThisTabButton.setOnClickListener { hideDialog() @@ -186,6 +188,14 @@ class SingleTabFireDialog : BottomSheetDialogFragment(), FireDialog { } } + private fun dispatchDeleteAll() { + if (arguments?.getString(ARG_ORIGIN) == ORIGIN_CHAT_HISTORY) { + viewModel.onDeleteSelectedChatsClicked() + } else { + viewModel.onDeleteAllClicked() + } + } + private fun configureBottomSheet() { (dialog as? BottomSheetDialog)?.behavior?.apply { state = BottomSheetBehavior.STATE_EXPANDED @@ -271,14 +281,11 @@ class SingleTabFireDialog : BottomSheetDialogFragment(), FireDialog { binding.fireIcon.gone() } - val titleRes = if (state.stateData.isDuckAiTab && state.isDeleteThisTabButtonVisible) { - R.string.singleTabFireDialogTitleDuckAi - } else if (state.stateData.isDuckAiChatsSelected) { - R.string.singleTabFireDialogTitleWithChats - } else { - R.string.singleTabFireDialogTitle + binding.dialogTitle.text = when (val source = state.stateData.titleSource) { + is SingleTabFireDialogViewModel.TitleSource.Static -> requireContext().getString(source.resId) + is SingleTabFireDialogViewModel.TitleSource.Plural -> + resources.getQuantityString(source.pluralsId, source.count, source.count) } - binding.dialogTitle.text = requireContext().getString(titleRes) if (state.isDeleteThisTabButtonVisible) { binding.deleteThisTabButton.show() @@ -439,6 +446,10 @@ class SingleTabFireDialog : BottomSheetDialogFragment(), FireDialog { FireDialogProvider.FireDialogOrigin.Browser } } + ORIGIN_CHAT_HISTORY -> { + val urls = arguments?.getStringArrayList(ARG_SELECTED_CHAT_URLS)?.toSet().orEmpty() + FireDialogProvider.FireDialogOrigin.ChatHistory(selectedChatUrls = urls) + } else -> FireDialogProvider.FireDialogOrigin.Browser } } @@ -449,6 +460,8 @@ class SingleTabFireDialog : BottomSheetDialogFragment(), FireDialog { arguments = bundleOf( ARG_ORIGIN to origin.tag(), ARG_TAB_ID to (origin as? FireDialogProvider.FireDialogOrigin.Hatch)?.tabId, + ARG_SELECTED_CHAT_URLS to (origin as? FireDialogProvider.FireDialogOrigin.ChatHistory) + ?.selectedChatUrls?.let { ArrayList(it) }, ) } } @@ -459,6 +472,7 @@ class SingleTabFireDialog : BottomSheetDialogFragment(), FireDialog { FireDialogProvider.FireDialogOrigin.TabSwitcher -> ORIGIN_TAB_SWITCHER FireDialogProvider.FireDialogOrigin.DuckAiContextualChat -> ORIGIN_DUCK_AI_CONTEXTUAL_CHAT is FireDialogProvider.FireDialogOrigin.Hatch -> ORIGIN_HATCH + is FireDialogProvider.FireDialogOrigin.ChatHistory -> ORIGIN_CHAT_HISTORY } } } diff --git a/app/src/main/java/com/duckduckgo/app/global/view/SingleTabFireDialogViewModel.kt b/app/src/main/java/com/duckduckgo/app/global/view/SingleTabFireDialogViewModel.kt index d09ea4af019c..be473a9b5f73 100644 --- a/app/src/main/java/com/duckduckgo/app/global/view/SingleTabFireDialogViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/global/view/SingleTabFireDialogViewModel.kt @@ -20,6 +20,7 @@ import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.api.WebViewCapabilityChecker import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability.DeleteBrowsingData import com.duckduckgo.app.fire.ManualDataClearing @@ -163,6 +164,27 @@ class SingleTabFireDialogViewModel @Inject constructor( } } + fun onDeleteSelectedChatsClicked() { + val selectedChatUrls = (origin.value as? FireDialogOrigin.ChatHistory)?.selectedChatUrls ?: return + viewModelScope.launch { + shouldRestartAfterClearing = false + command.send(Command.OnClearStarted) + + val fireAnimationEnabled = withContext(dispatcherProvider.io()) { + settingsDataStore.fireAnimationEnabled + } + if (fireAnimationEnabled) { + command.send(Command.PlayAnimation) + } + + withContext(dispatcherProvider.io()) { + dataClearing.clearSelectedDuckAiChats(selectedChatUrls) + } + + command.send(Command.ClearingComplete) + } + } + fun onDeleteThisTabClicked() { viewModelScope.launch { shouldRestartAfterClearing = false @@ -230,28 +252,51 @@ class SingleTabFireDialogViewModel @Inject constructor( } private suspend fun mapToViewState(dialogOrigin: FireDialogOrigin): ViewState.Loaded { + // Non-tab origins skip the tab/download/WebView probes — there's no tab to reason about. + val isTabAware = dialogOrigin !is FireDialogOrigin.ChatHistory + val isDuckAiChatsSelected = - fireDataStore.isManualClearOptionSelected(FireClearOption.DUCKAI_CHATS) - val isDeleteBrowsingDataSupported = webViewCapabilityChecker.isSupported(DeleteBrowsingData) - val shownCount = settingsDataStore.singleTabFireDialogShownCount - val downloads = downloadsRepository.getDownloads() - val targetTabUrl = resolveTargetTabUrl(dialogOrigin) + isTabAware && fireDataStore.isManualClearOptionSelected(FireClearOption.DUCKAI_CHATS) + val isDeleteBrowsingDataSupported = isTabAware && webViewCapabilityChecker.isSupported(DeleteBrowsingData) + val downloads = if (isTabAware) downloadsRepository.getDownloads() else emptyList() + val targetTabUrl = if (isTabAware) resolveTargetTabUrl(dialogOrigin) else null + val tabCount = if (isTabAware) tabRepository.getOpenTabCount() else 0 val isDuckAiTab = dialogOrigin == DuckAiContextualChat || - targetTabUrl?.let { duckChat.isDuckChatUrl(it.toUri()) } ?: false - val tabCount = tabRepository.getOpenTabCount() + targetTabUrl?.let { duckChat.isDuckChatUrl(it.toUri()) } == true val isFireAnimationUpdateEnabled = withContext(dispatcherProvider.io()) { brandDesignUpdateToggles.fireAnimationUpdate().isEnabled() } + val isDeleteThisTabAvailable = (isDeleteBrowsingDataSupported && dialogOrigin == Browser) || + dialogOrigin == DuckAiContextualChat || + dialogOrigin is Hatch + val shownCount = settingsDataStore.singleTabFireDialogShownCount + + val titleSource: TitleSource = when (dialogOrigin) { + is FireDialogOrigin.ChatHistory -> TitleSource.Plural( + pluralsId = R.plurals.fireDialogDeleteCountTitle, + count = dialogOrigin.count, + ) + else -> { + val titleResId = when { + isDuckAiTab && isDeleteThisTabAvailable -> R.string.singleTabFireDialogTitleDuckAi + isDuckAiChatsSelected -> R.string.singleTabFireDialogTitleWithChats + else -> R.string.singleTabFireDialogTitle + } + TitleSource.Static(titleResId) + } + } + return ViewState.Loaded( stateData = ViewState.Loaded.StateData( isDuckAiChatsSelected = isDuckAiChatsSelected, isSingleTabEnabled = isDeleteBrowsingDataSupported, isDuckAiTab = isDuckAiTab, tabCount = tabCount, - isSiteDataSubtitleEligible = shownCount < DIALOG_WARNING_MESSAGE_SHOWN_LIMIT, + isSiteDataSubtitleEligible = isTabAware && shownCount < DIALOG_WARNING_MESSAGE_SHOWN_LIMIT, isDownloadsSubtitleEligible = downloads.any { download -> download.downloadStatus == DownloadStatus.STARTED }, isFirePictogramVisible = settingsDataStore.fireAnimationEnabled, isFireAnimationUpdateEnabled = isFireAnimationUpdateEnabled, + titleSource = titleSource, ), origin = dialogOrigin, ) @@ -322,10 +367,16 @@ class SingleTabFireDialogViewModel @Inject constructor( val isDownloadsSubtitleEligible: Boolean = false, val isFirePictogramVisible: Boolean = true, val isFireAnimationUpdateEnabled: Boolean = false, + val titleSource: TitleSource = TitleSource.Static(R.string.singleTabFireDialogTitle), ) } } + sealed class TitleSource { + data class Static(val resId: Int) : TitleSource() + data class Plural(val pluralsId: Int, val count: Int) : TitleSource() + } + sealed class Command { data object PlayAnimation : Command() data object ClearingComplete : Command() diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 31c1310c8d68..1e87ecfbadf5 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -129,4 +129,9 @@ Inferno Classic Inferno + + + Delete %1$d chat? + Delete %1$d chats? + diff --git a/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt b/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt index 5dd31627ea2a..148935179622 100644 --- a/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt +++ b/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt @@ -123,6 +123,7 @@ class DataClearingTest { runBlocking { whenever(mockClearDataAction.clearDataForSpecificDomains(any())).thenReturn(ClearDataResult.Success) whenever(mockFireDataStore.getManualClearOptions()).thenReturn(emptySet()) + whenever(mockTabRepository.getTabs()).thenReturn(emptyList()) } testee = DataClearing( fireDataStore = mockFireDataStore, @@ -194,6 +195,17 @@ class DataClearingTest { verify(mockClearDataAction, never()).killAndRestartProcess(any(), any(), any()) } + @Test + fun `manual clear with DuckAi chats dispatches DuckChats All via trigger`() = runTest { + configureManualOptions(setOf(FireClearOption.DUCKAI_CHATS)) + + testee.clearDataUsingManualFireOptions(shouldRestartIfRequired = false, wasAppUsedSinceLastClear = true) + + verify(mockClearDataAction).clearDuckAiChatsOnly() + verify(mockDataClearingTrigger).clearData(eq(setOf(ClearableData.DuckChats.All))) + verify(mockTabRepository, never()).deleteTabs(any()) + } + @Test fun whenManualClearWithAllOptions_thenClearAllAndSetFlag() = runTest { configureManualOptions(setOf(FireClearOption.TABS, FireClearOption.DATA, FireClearOption.DUCKAI_CHATS)) @@ -1009,6 +1021,57 @@ class DataClearingTest { verify(mockNavigationHistory).removeHistoryForTab("tab1") } + // --- clearSelectedDuckAiChats --- + + @Test + fun `clearSelectedDuckAiChats with urls dispatches Selected via trigger`() = runTest { + val urls = setOf("https://duck.ai?chatID=a", "https://duck.ai?chatID=b") + + testee.clearSelectedDuckAiChats(urls) + + verify(mockDataClearingTrigger).clearData(eq(setOf(ClearableData.DuckChats.Selected(urls)))) + } + + @Test + fun `clearSelectedDuckAiChats does not wipe DuckAi web storage`() = runTest { + testee.clearSelectedDuckAiChats(setOf("https://duck.ai?chatID=a")) + + verify(mockClearDataAction, never()).clearDuckAiChatsOnly() + } + + @Test + fun `clearSelectedDuckAiChats does not touch tabs or browser data`() = runTest { + testee.clearSelectedDuckAiChats(setOf("https://duck.ai?chatID=a")) + + verify(mockClearDataAction, never()).clearTabsOnly() + verify(mockClearDataAction, never()).clearBrowserDataOnly(any()) + verify(mockTabRepository, never()).deleteTabs(any()) + } + + @Test + fun `clearSelectedDuckAiChats does not touch fire button orchestration flags`() = runTest { + testee.clearSelectedDuckAiChats(setOf("https://duck.ai?chatID=a")) + + verify(mockClearDataAction, never()).setAppUsedSinceLastClearFlag(any()) + verify(mockClearDataAction, never()).killAndRestartProcess(any(), any(), any()) + } + + @Test + fun `clearSelectedDuckAiChats with empty set is a no-op`() = runTest { + testee.clearSelectedDuckAiChats(emptySet()) + + verify(mockDataClearingTrigger, never()).clearData(any()) + } + + @Test + fun `clearSelectedDuckAiChats with feature flag off is a no-op`() = runTest { + showClearDuckAIChatHistoryFlow.value = false + + testee.clearSelectedDuckAiChats(setOf("https://duck.ai?chatID=a")) + + verify(mockDataClearingTrigger, never()).clearData(any()) + } + private suspend fun configureManualOptions(options: Set) { whenever(mockFireDataStore.getManualClearOptions()).thenReturn(options) } diff --git a/app/src/test/java/com/duckduckgo/app/fire/DuckAiTabsCleanupPluginTest.kt b/app/src/test/java/com/duckduckgo/app/fire/DuckAiTabsCleanupPluginTest.kt new file mode 100644 index 000000000000..408c2f67060d --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/fire/DuckAiTabsCleanupPluginTest.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.fire + +import androidx.core.net.toUri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.tabs.model.TabEntity +import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.dataclearing.api.plugin.ClearableData +import com.duckduckgo.duckchat.api.DuckChat +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class DuckAiTabsCleanupPluginTest { + + @Mock + private lateinit var mockTabRepository: TabRepository + + @Mock + private lateinit var mockDuckChat: DuckChat + + private lateinit var testee: DuckAiTabsCleanupPlugin + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + runBlocking { + whenever(mockTabRepository.getTabs()).thenReturn(emptyList()) + } + testee = DuckAiTabsCleanupPlugin(mockTabRepository, mockDuckChat) + } + + @Test + fun `DuckChats All closes only DuckAi tabs`() = runTest { + val duckAiTab = TabEntity(tabId = "duck-ai", url = "https://duck.ai") + val browserTab = TabEntity(tabId = "browser", url = "https://example.com") + whenever(mockTabRepository.getTabs()).thenReturn(listOf(duckAiTab, browserTab)) + whenever(mockDuckChat.isDuckChatUrl(eq("https://duck.ai".toUri()))).thenReturn(true) + whenever(mockDuckChat.isDuckChatUrl(eq("https://example.com".toUri()))).thenReturn(false) + + testee.onClearData(setOf(ClearableData.DuckChats.All)) + + verify(mockTabRepository).deleteTabs(listOf("duck-ai")) + } + + @Test + fun `DuckChats All with no DuckAi tabs does not call deleteTabs`() = runTest { + whenever(mockTabRepository.getTabs()).thenReturn(listOf(TabEntity(tabId = "browser", url = "https://example.com"))) + whenever(mockDuckChat.isDuckChatUrl(any())).thenReturn(false) + + testee.onClearData(setOf(ClearableData.DuckChats.All)) + + verify(mockTabRepository, never()).deleteTabs(any()) + } + + @Test + fun `DuckChats All with no open tabs is a no-op`() = runTest { + testee.onClearData(setOf(ClearableData.DuckChats.All)) + + verify(mockTabRepository, never()).deleteTabs(any()) + } + + @Test + fun `DuckChats Selected closes only the matching tab`() = runTest { + val matchingTab = TabEntity(tabId = "tab1", url = "https://duck.ai?chatID=abc") + val unrelatedDuckAiTab = TabEntity(tabId = "tab2", url = "https://duck.ai?chatID=zzz") + val browserTab = TabEntity(tabId = "tab3", url = "https://example.com") + whenever(mockTabRepository.getTabs()).thenReturn(listOf(matchingTab, unrelatedDuckAiTab, browserTab)) + whenever(mockDuckChat.isDuckChatUrl(eq("https://duck.ai?chatID=abc".toUri()))).thenReturn(true) + whenever(mockDuckChat.isDuckChatUrl(eq("https://duck.ai?chatID=zzz".toUri()))).thenReturn(true) + whenever(mockDuckChat.isDuckChatUrl(eq("https://example.com".toUri()))).thenReturn(false) + + testee.onClearData(setOf(ClearableData.DuckChats.Selected(setOf("https://duck.ai?chatID=abc")))) + + verify(mockTabRepository).deleteTabs(listOf("tab1")) + } + + @Test + fun `DuckChats Selected with multiple matching urls closes all of them`() = runTest { + val t1 = TabEntity(tabId = "tab1", url = "https://duck.ai?chatID=a") + val t2 = TabEntity(tabId = "tab2", url = "https://duck.ai?chatID=b") + val t3 = TabEntity(tabId = "tab3", url = "https://example.com") + whenever(mockTabRepository.getTabs()).thenReturn(listOf(t1, t2, t3)) + whenever(mockDuckChat.isDuckChatUrl(eq("https://duck.ai?chatID=a".toUri()))).thenReturn(true) + whenever(mockDuckChat.isDuckChatUrl(eq("https://duck.ai?chatID=b".toUri()))).thenReturn(true) + whenever(mockDuckChat.isDuckChatUrl(eq("https://example.com".toUri()))).thenReturn(false) + + testee.onClearData( + setOf(ClearableData.DuckChats.Selected(setOf("https://duck.ai?chatID=a", "https://duck.ai?chatID=b"))), + ) + + verify(mockTabRepository).deleteTabs(listOf("tab1", "tab2")) + } + + @Test + fun `DuckChats Selected with no matching tab does not call deleteTabs`() = runTest { + val unrelated = TabEntity(tabId = "tab1", url = "https://duck.ai?chatID=other") + whenever(mockTabRepository.getTabs()).thenReturn(listOf(unrelated)) + whenever(mockDuckChat.isDuckChatUrl(any())).thenReturn(true) + + testee.onClearData(setOf(ClearableData.DuckChats.Selected(setOf("https://duck.ai?chatID=abc")))) + + verify(mockTabRepository, never()).deleteTabs(any()) + } + + @Test + fun `DuckChats Selected closes every tab sharing the same chatID`() = runTest { + val t1 = TabEntity(tabId = "tab1", url = "https://duck.ai?chatID=abc") + val t2 = TabEntity(tabId = "tab2", url = "https://duck.ai?chatID=abc") + val t3 = TabEntity(tabId = "tab3", url = "https://duck.ai?chatID=abc") + whenever(mockTabRepository.getTabs()).thenReturn(listOf(t1, t2, t3)) + whenever(mockDuckChat.isDuckChatUrl(any())).thenReturn(true) + + testee.onClearData(setOf(ClearableData.DuckChats.Selected(setOf("https://duck.ai?chatID=abc")))) + + verify(mockTabRepository).deleteTabs(listOf("tab1", "tab2", "tab3")) + } + + @Test + fun `tab url drifted from canonical chat url still closes the tab by chatID`() = runTest { + // Tab URLs that share the same chatID but differ in path, extra params, fragments — + // the kinds of drift that happen as the user interacts with the chat over a session. + val original = TabEntity(tabId = "tab1", url = "https://duck.ai?chatID=abc") + val withPath = TabEntity(tabId = "tab2", url = "https://duck.ai/chat?chatID=abc") + val withExtraParams = TabEntity(tabId = "tab3", url = "https://duck.ai?chatID=abc&model=gpt&session=xyz") + val withFragment = TabEntity(tabId = "tab4", url = "https://duck.ai?chatID=abc#message-5") + whenever(mockTabRepository.getTabs()).thenReturn(listOf(original, withPath, withExtraParams, withFragment)) + whenever(mockDuckChat.isDuckChatUrl(any())).thenReturn(true) + + testee.onClearData(setOf(ClearableData.DuckChats.Selected(setOf("https://duck.ai?chatID=abc")))) + + verify(mockTabRepository).deleteTabs(listOf("tab1", "tab2", "tab3", "tab4")) + } + + @Test + fun `Selected url with no chatID query param is a no-op`() = runTest { + val anyTab = TabEntity(tabId = "tab1", url = "https://duck.ai?chatID=abc") + whenever(mockTabRepository.getTabs()).thenReturn(listOf(anyTab)) + whenever(mockDuckChat.isDuckChatUrl(any())).thenReturn(true) + + testee.onClearData(setOf(ClearableData.DuckChats.Selected(setOf("https://duck.ai")))) + + verify(mockTabRepository, never()).deleteTabs(any()) + } + + @Test + fun `DuckChats Selected with empty url set is a no-op`() = runTest { + testee.onClearData(setOf(ClearableData.DuckChats.Selected(emptySet()))) + + verify(mockTabRepository, never()).deleteTabs(any()) + } + + @Test + fun `unrelated ClearableData is a no-op`() = runTest { + testee.onClearData(setOf(ClearableData.BrowserData.All, ClearableData.Tabs.All)) + + verify(mockTabRepository, never()).deleteTabs(any()) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/global/view/SingleTabFireDialogViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/global/view/SingleTabFireDialogViewModelTest.kt index dda239ff2e9f..df73f206733f 100644 --- a/app/src/test/java/com/duckduckgo/app/global/view/SingleTabFireDialogViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/global/view/SingleTabFireDialogViewModelTest.kt @@ -1101,6 +1101,126 @@ class SingleTabFireDialogViewModelTest { // endregion + // region onDeleteSelectedChatsClicked + + @Test + fun `when delete selected chats clicked then clearSelectedDuckAiChats is dispatched with the origin urls`() = runTest { + val urls = setOf("https://duck.ai?chatID=a", "https://duck.ai?chatID=b") + testee = createViewModel() + testee.setOrigin(FireDialogOrigin.ChatHistory(selectedChatUrls = urls)) + + testee.onDeleteSelectedChatsClicked() + + coroutineTestRule.testScope.testScheduler.advanceUntilIdle() + + verify(mockDataClearing).clearSelectedDuckAiChats(urls) + verify(mockDataClearing, never()).clearDataUsingManualFireOptions(any(), any()) + } + + @Test + fun `when delete selected chats clicked then process is not restarted`() = runTest { + val urls = setOf("https://duck.ai?chatID=a") + testee = createViewModel() + testee.setOrigin(FireDialogOrigin.ChatHistory(selectedChatUrls = urls)) + + testee.onDeleteSelectedChatsClicked() + + coroutineTestRule.testScope.testScheduler.advanceUntilIdle() + + assertFalse(testee.shouldRestartAfterClearing) + } + + @Test + fun `when delete selected chats clicked then no fire-dialog pixels are fired`() = runTest { + testee = createViewModel() + testee.setOrigin(FireDialogOrigin.ChatHistory(selectedChatUrls = setOf("https://duck.ai?chatID=a"))) + + testee.onDeleteSelectedChatsClicked() + + coroutineTestRule.testScope.testScheduler.advanceUntilIdle() + + verify(mockPixel, never()).enqueueFire(eq(FIRE_DIALOG_CLEAR_PRESSED), any(), any(), any()) + verify(mockPixel, never()).enqueueFire(eq(FIRE_DIALOG_CLEAR_PRESSED_DAILY), any(), any(), any()) + verify(mockPixel, never()).enqueueFire(eq(PRODUCT_TELEMETRY_SURFACE_DATA_CLEARING), any(), any(), any()) + verify(mockPixel, never()).enqueueFire(eq(FIRE_DIALOG_ANIMATION), any(), any(), any()) + } + + @Test + fun `when delete selected chats clicked then fire button counters are not touched`() = runTest { + testee = createViewModel() + testee.setOrigin(FireDialogOrigin.ChatHistory(selectedChatUrls = setOf("https://duck.ai?chatID=a"))) + + testee.onDeleteSelectedChatsClicked() + + coroutineTestRule.testScope.testScheduler.advanceUntilIdle() + + verify(mockFireButtonStore, never()).incrementFireButtonUseCount() + verify(mockUserEventsStore, never()).registerUserEvent(UserEventKey.FIRE_BUTTON_EXECUTED) + } + + @Test + fun `when delete selected chats clicked then data clearing wide event is not started`() = runTest { + testee = createViewModel() + testee.setOrigin(FireDialogOrigin.ChatHistory(selectedChatUrls = setOf("https://duck.ai?chatID=a"))) + + testee.onDeleteSelectedChatsClicked() + + coroutineTestRule.testScope.testScheduler.advanceUntilIdle() + + verify(mockDataClearingWideEvent, never()).start(any(), any()) + verify(mockDataClearingWideEvent, never()).finishSuccess() + } + + @Test + fun `when delete selected chats clicked with animation enabled then play animation command is sent`() = runTest { + whenever(mockSettingsDataStore.fireAnimationEnabled).thenReturn(true) + testee = createViewModel() + testee.setOrigin(FireDialogOrigin.ChatHistory(selectedChatUrls = setOf("https://duck.ai?chatID=a"))) + + testee.commands().test { + testee.onDeleteSelectedChatsClicked() + + awaitItem() // OnShow from init + assertEquals(Command.OnClearStarted, awaitItem()) + assertEquals(Command.PlayAnimation, awaitItem()) + assertEquals(Command.ClearingComplete, awaitItem()) + + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun `when delete selected chats clicked with animation disabled then play animation command is not sent`() = runTest { + whenever(mockSettingsDataStore.fireAnimationEnabled).thenReturn(false) + testee = createViewModel() + testee.setOrigin(FireDialogOrigin.ChatHistory(selectedChatUrls = setOf("https://duck.ai?chatID=a"))) + + testee.commands().test { + testee.onDeleteSelectedChatsClicked() + + awaitItem() // OnShow from init + assertEquals(Command.OnClearStarted, awaitItem()) + assertEquals(Command.ClearingComplete, awaitItem()) + + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun `when delete selected chats clicked without ChatHistory origin then nothing happens`() = runTest { + testee = createViewModel() + testee.setOrigin(FireDialogOrigin.Browser) + + testee.onDeleteSelectedChatsClicked() + + coroutineTestRule.testScope.testScheduler.advanceUntilIdle() + + verify(mockDataClearing, never()).clearSelectedDuckAiChats(any()) + verify(mockDataClearing, never()).clearDataUsingManualFireOptions(any(), any()) + } + + // endregion + // region onDeleteThisTabClicked @Test diff --git a/data-clearing/data-clearing-api/src/main/java/com/duckduckgo/dataclearing/api/fire/FireDialogProvider.kt b/data-clearing/data-clearing-api/src/main/java/com/duckduckgo/dataclearing/api/fire/FireDialogProvider.kt index 1e550db3e4b4..d84a87407d63 100644 --- a/data-clearing/data-clearing-api/src/main/java/com/duckduckgo/dataclearing/api/fire/FireDialogProvider.kt +++ b/data-clearing/data-clearing-api/src/main/java/com/duckduckgo/dataclearing/api/fire/FireDialogProvider.kt @@ -59,5 +59,17 @@ interface FireDialogProvider { * @property tabId The id of the tab the Hatch is offering to burn. */ data class Hatch(val tabId: String) : FireDialogOrigin() + + /** + * Chat history screen — bulk-delete confirmation. Scopes the clear to [selectedChatUrls]. + * An empty set is a valid no-op shape; the dialog still renders but the destructive + * action does nothing. + * + * @property selectedChatUrls The chat URLs to clear on confirm. Title count + * ("Delete N chats?") is derived from this set's size. + */ + data class ChatHistory(val selectedChatUrls: Set) : FireDialogOrigin() { + val count: Int get() = selectedChatUrls.size + } } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt index 0f1850573bfe..f62dfa388b0c 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt @@ -183,6 +183,9 @@ interface DuckChatInternal : DuckChat { */ fun openWithChatId(chatId: String) + /** Single source of truth for the Duck.ai chat URL shape. */ + fun buildChatUrl(chatId: String): String + /** * Calls onClose when a close event is emitted. */ @@ -829,6 +832,10 @@ class RealDuckChat @Inject constructor( openDuckChat(parameters = mapOf(CHAT_ID_QUERY_NAME to chatId), forceNewSession = true) } + override fun buildChatUrl(chatId: String): String { + return appendParameters(mapOf(CHAT_ID_QUERY_NAME to chatId), getDuckChatLink()) + } + override suspend fun setDefaultTogglePosition(position: DefaultTogglePosition) { duckChatFeatureRepository.setDefaultTogglePosition(position.name) } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryAdapter.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryAdapter.kt index 8e35479b2660..d55c31a3fbfe 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryAdapter.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryAdapter.kt @@ -25,16 +25,19 @@ class ChatHistoryAdapter( private val onChatClicked: (ChatHistoryItem) -> Unit, private val onChatMoreClicked: (ChatHistoryItem, android.view.View) -> Unit, private val onChatLongClicked: (ChatHistoryItem) -> Boolean = { false }, + private val onSelectAllClicked: () -> Unit = {}, ) : ListAdapter(Diff) { override fun getItemViewType(position: Int): Int = when (getItem(position)) { is ChatHistoryListEntry.Header -> VIEW_TYPE_HEADER is ChatHistoryListEntry.Row -> VIEW_TYPE_ROW + is ChatHistoryListEntry.SelectAllHeader -> VIEW_TYPE_SELECT_ALL } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = when (viewType) { VIEW_TYPE_HEADER -> ChatHistorySectionHeaderViewHolder.create(parent) VIEW_TYPE_ROW -> ChatHistoryViewHolder.create(parent) + VIEW_TYPE_SELECT_ALL -> ChatHistorySelectAllViewHolder.create(parent) else -> error("Unknown viewType=$viewType") } @@ -43,28 +46,58 @@ class ChatHistoryAdapter( is ChatHistoryListEntry.Header -> (holder as ChatHistorySectionHeaderViewHolder).bind(entry.labelRes) is ChatHistoryListEntry.Row -> (holder as ChatHistoryViewHolder).bind( item = entry.item, + selected = entry.selected, onClick = onChatClicked, onMoreClick = onChatMoreClicked, onLongClick = onChatLongClicked, ) + is ChatHistoryListEntry.SelectAllHeader -> (holder as ChatHistorySelectAllViewHolder).bind( + allSelected = entry.allSelected, + onClick = onSelectAllClicked, + ) + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: MutableList) { + val selectionChange = payloads.firstOrNull { it is RowChange.SelectionChanged } as? RowChange.SelectionChanged + val entry = getItem(position) + if (selectionChange != null && holder is ChatHistoryViewHolder && entry is ChatHistoryListEntry.Row) { + holder.animateSelectionChange(entry.item, selectionChange.selected) + } else { + onBindViewHolder(holder, position) } } + private sealed interface RowChange { + data class SelectionChanged(val selected: Boolean) : RowChange + } + private object Diff : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: ChatHistoryListEntry, newItem: ChatHistoryListEntry): Boolean = when { oldItem is ChatHistoryListEntry.Header && newItem is ChatHistoryListEntry.Header -> oldItem.labelRes == newItem.labelRes oldItem is ChatHistoryListEntry.Row && newItem is ChatHistoryListEntry.Row -> oldItem.item.chatId == newItem.item.chatId + oldItem is ChatHistoryListEntry.SelectAllHeader && newItem is ChatHistoryListEntry.SelectAllHeader -> true else -> false } override fun areContentsTheSame(oldItem: ChatHistoryListEntry, newItem: ChatHistoryListEntry): Boolean = oldItem == newItem + + override fun getChangePayload(oldItem: ChatHistoryListEntry, newItem: ChatHistoryListEntry): Any? { + if (oldItem is ChatHistoryListEntry.Row && newItem is ChatHistoryListEntry.Row && + oldItem.item == newItem.item && oldItem.selected != newItem.selected + ) { + return RowChange.SelectionChanged(newItem.selected) + } + return null + } } private companion object { const val VIEW_TYPE_HEADER = 0 const val VIEW_TYPE_ROW = 1 + const val VIEW_TYPE_SELECT_ALL = 2 } } 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 aedc36025139..4dd3808f47fa 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 @@ -20,13 +20,13 @@ import android.os.Bundle import android.view.MenuItem import android.view.View import androidx.activity.OnBackPressedCallback +import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import com.duckduckgo.anvil.annotations.InjectWith -import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.common.ui.DuckDuckGoFragment import com.duckduckgo.common.ui.menu.PopupMenu import com.duckduckgo.common.ui.view.SearchBar @@ -35,13 +35,15 @@ import com.duckduckgo.common.ui.view.show import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.common.utils.FragmentViewModelFactory import com.duckduckgo.common.utils.extensions.hideKeyboard +import com.duckduckgo.dataclearing.api.fire.FireDialog +import com.duckduckgo.dataclearing.api.fire.FireDialogProvider import com.duckduckgo.di.scopes.FragmentScope -import com.duckduckgo.duckchat.impl.DuckChatInternal import com.duckduckgo.duckchat.impl.R import com.duckduckgo.duckchat.impl.databinding.FragmentChatHistoryBinding import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import logcat.logcat import javax.inject.Inject @@ -52,10 +54,7 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { lateinit var viewModelFactory: FragmentViewModelFactory @Inject - lateinit var duckChat: DuckChatInternal - - @Inject - lateinit var pixel: Pixel + lateinit var fireDialogProvider: FireDialogProvider private val binding: FragmentChatHistoryBinding by viewBinding() private val viewModel: ChatHistoryViewModel by lazy { @@ -63,13 +62,18 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { } private val adapter = ChatHistoryAdapter( - onChatClicked = { item -> duckChat.openWithChatId(item.chatId) }, - onChatMoreClicked = { _, anchor -> showRowPopup(anchor) }, + onChatClicked = { item -> viewModel.onChatRowClicked(item.chatId) }, + onChatMoreClicked = { item, anchor -> showRowPopup(item, anchor) }, + onChatLongClicked = { item -> viewModel.onChatRowLongClicked(item.chatId) }, + onSelectAllClicked = { viewModel.onSelectAllToggled() }, ) private val onBackPressedCallback = object : OnBackPressedCallback(enabled = false) { override fun handleOnBackPressed() { - hideSearchBar() + when { + binding.searchBar.isVisible -> hideSearchBar() + viewModel.isSelectMode() -> viewModel.onSelectModeCancelled() + } } } @@ -85,7 +89,7 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { binding.chatHistoryList.layoutManager = LinearLayoutManager(requireContext()) binding.chatHistoryList.adapter = adapter - binding.chatHistoryEmptyState.setOnPrimaryCtaClickListener { duckChat.openDuckChat() } + binding.chatHistoryEmptyState.setOnPrimaryCtaClickListener { viewModel.onOpenDuckAiClicked() } binding.searchBar.onAction { action -> when (action) { @@ -96,6 +100,21 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback) + childFragmentManager.setFragmentResultListener(FireDialog.REQUEST_KEY, viewLifecycleOwner) { _, bundle -> + val event = bundle.getString(FireDialog.RESULT_KEY_EVENT) + val confirmation = (viewModel.uiState.value as? ChatHistoryUiState.Loaded)?.confirmation + when (event) { + FireDialog.EVENT_ON_CLEAR_STARTED, + FireDialog.EVENT_CLEAR_WITHOUT_RESTART_STARTED, + -> when (confirmation) { + is ChatHistoryUiState.PendingConfirmation.FireAll -> viewModel.onFireAllConfirmed() + is ChatHistoryUiState.PendingConfirmation.DeleteSelected -> viewModel.onDeleteSelectedConfirmed() + null -> Unit + } + FireDialog.EVENT_ON_CANCEL -> viewModel.onConfirmationCancelled() + } + } + viewModel.uiState .flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED) .onEach(::render) @@ -108,31 +127,105 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { ChatHistoryUiState.Loading -> { binding.chatHistoryList.visibility = View.GONE binding.chatHistoryEmptyState.visibility = View.GONE + applyDefaultToolbar() + setFireActionVisible(false) } ChatHistoryUiState.Empty -> { binding.chatHistoryList.visibility = View.GONE binding.chatHistoryEmptyState.visibility = View.VISIBLE adapter.submitList(emptyList()) + applyDefaultToolbar() + setFireActionVisible(false) } is ChatHistoryUiState.Loaded -> { binding.chatHistoryList.visibility = View.VISIBLE binding.chatHistoryEmptyState.visibility = View.GONE - adapter.submitList(buildEntries(state)) + val selectMode = state.mode as? ChatHistoryUiState.Mode.Selecting + adapter.submitList(buildEntries(state, selectMode)) + if (selectMode != null) { + applySelectModeToolbar(selectMode.selectedChatIds.size) + setFireActionVisible(selectMode.selectedChatIds.isNotEmpty()) + } else { + applyDefaultToolbar() + // Fire-all wipes every chat including Pinned — show whenever any chat is present. + setFireActionVisible(state.pinned.isNotEmpty() || state.recent.isNotEmpty()) + } + renderConfirmation(state.confirmation) } } + // Re-derive every render so a transition out of Loaded (e.g. last chat deleted externally) + // can't leave us intercepting back presses with no overlay to dismiss. + onBackPressedCallback.isEnabled = shouldInterceptBack(state) + } + + private fun shouldInterceptBack(state: ChatHistoryUiState): Boolean { + if (binding.searchBar.isVisible) return true + val loaded = state as? ChatHistoryUiState.Loaded ?: return false + return loaded.mode is ChatHistoryUiState.Mode.Selecting + } + + private fun applyDefaultToolbar() { + binding.toolbar.setNavigationIcon(com.duckduckgo.mobile.android.R.drawable.ic_arrow_left_24) + binding.toolbar.navigationContentDescription = null + binding.toolbar.setNavigationOnClickListener { requireActivity().onBackPressedDispatcher.onBackPressed() } + binding.toolbar.setTitle(R.string.duck_ai_chat_history_title) + binding.toolbar.menu.findItem(R.id.chat_history_action_search)?.isVisible = true + binding.toolbar.menu.findItem(R.id.chat_history_action_overflow)?.isVisible = true } - private fun buildEntries(state: ChatHistoryUiState.Loaded): List = buildList { + private fun applySelectModeToolbar(count: Int) { + binding.toolbar.setNavigationIcon(com.duckduckgo.mobile.android.R.drawable.ic_arrow_left_24) + binding.toolbar.navigationContentDescription = + getString(R.string.duck_ai_chat_history_exit_select_mode_content_description) + binding.toolbar.setNavigationOnClickListener { viewModel.onSelectModeCancelled() } + binding.toolbar.title = count.toString() + binding.toolbar.menu.findItem(R.id.chat_history_action_search)?.isVisible = false + binding.toolbar.menu.findItem(R.id.chat_history_action_overflow)?.isVisible = false + } + + private fun buildEntries( + state: ChatHistoryUiState.Loaded, + selectMode: ChatHistoryUiState.Mode.Selecting?, + ): List = buildList { + if (selectMode != null) { + val visibleIds = (state.pinned + state.recent).map { it.chatId }.toSet() + val allSelected = visibleIds.isNotEmpty() && selectMode.selectedChatIds == visibleIds + add(ChatHistoryListEntry.SelectAllHeader(allSelected = allSelected)) + } if (state.pinned.isNotEmpty()) { if (!state.searchActive) add(ChatHistoryListEntry.Header(R.string.duck_ai_chat_history_section_pinned)) - state.pinned.forEach { add(ChatHistoryListEntry.Row(it)) } + state.pinned.forEach { item -> + val selected = selectMode != null && item.chatId in selectMode.selectedChatIds + add(ChatHistoryListEntry.Row(item = item, selected = selected)) + } } if (state.recent.isNotEmpty()) { if (!state.searchActive) add(ChatHistoryListEntry.Header(R.string.duck_ai_chat_history_section_recent)) - state.recent.forEach { add(ChatHistoryListEntry.Row(it)) } + state.recent.forEach { item -> + val selected = selectMode != null && item.chatId in selectMode.selectedChatIds + add(ChatHistoryListEntry.Row(item = item, selected = selected)) + } } } + private fun renderConfirmation(confirmation: ChatHistoryUiState.PendingConfirmation?) { + if (confirmation == null) return + if (childFragmentManager.findFragmentByTag(FIRE_DIALOG_TAG) != null) return + + val selectedChatUrls = viewModel.chatUrlsForDialog().orEmpty() + viewLifecycleOwner.lifecycleScope.launch { + val dialog = fireDialogProvider.createFireDialog( + FireDialogProvider.FireDialogOrigin.ChatHistory(selectedChatUrls = selectedChatUrls), + ) + if (childFragmentManager.findFragmentByTag(FIRE_DIALOG_TAG) != null) return@launch + dialog.show(childFragmentManager, FIRE_DIALOG_TAG) + } + } + + private fun setFireActionVisible(visible: Boolean) { + binding.toolbar.menu.findItem(R.id.chat_history_action_fire)?.isVisible = visible + } + private fun onMenuItemClicked(item: MenuItem): Boolean = when (item.itemId) { R.id.chat_history_action_overflow -> { showToolbarOverflowPopup() @@ -143,7 +236,7 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { true } R.id.chat_history_action_fire -> { - showComingSoonSnackbar() + viewModel.onFireIconClicked() true } else -> false @@ -157,28 +250,28 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { } private fun hideSearchBar() { - onBackPressedCallback.isEnabled = false binding.searchBar.handle(SearchBar.Event.DismissSearchBar) requireActivity().hideKeyboard() binding.toolbar.show() viewModel.onSearchClosed() + // onBackPressedCallback.isEnabled is reset by render() — select mode may still be active. } private fun showToolbarOverflowPopup() { val anchor = binding.toolbar.findViewById(R.id.chat_history_action_overflow) ?: return val popup = PopupMenu(layoutInflater, R.layout.popup_chat_history_overflow) val view = popup.contentView - popup.onMenuItemClicked(view.findViewById(R.id.select)) { showComingSoonSnackbar() } + popup.onMenuItemClicked(view.findViewById(R.id.select)) { viewModel.onEnterSelectMode() } popup.show(binding.root, anchor) } - private fun showRowPopup(anchor: View) { + 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() } popup.onMenuItemClicked(view.findViewById(R.id.rename)) { showComingSoonSnackbar() } popup.onMenuItemClicked(view.findViewById(R.id.download)) { showComingSoonSnackbar() } - popup.onMenuItemClicked(view.findViewById(R.id.delete)) { showComingSoonSnackbar() } + popup.onMenuItemClicked(view.findViewById(R.id.delete)) { viewModel.onDeleteSingleChat(item.chatId) } popup.show(binding.root, anchor) } @@ -187,6 +280,8 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { } companion object { + private const val FIRE_DIALOG_TAG = "chat_history_fire_dialog" + fun newInstance(): ChatHistoryFragment = ChatHistoryFragment() } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryListEntry.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryListEntry.kt index abecd7dfd9e9..a3e72e5d56c3 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryListEntry.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryListEntry.kt @@ -20,5 +20,6 @@ import androidx.annotation.StringRes sealed interface ChatHistoryListEntry { data class Header(@StringRes val labelRes: Int) : ChatHistoryListEntry - data class Row(val item: ChatHistoryItem) : ChatHistoryListEntry + data class Row(val item: ChatHistoryItem, val selected: Boolean = false) : ChatHistoryListEntry + data class SelectAllHeader(val allSelected: Boolean) : ChatHistoryListEntry } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistorySelectAllViewHolder.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistorySelectAllViewHolder.kt new file mode 100644 index 000000000000..87cf8cd44051 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistorySelectAllViewHolder.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckchat.impl.history + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.duckduckgo.common.ui.view.text.DaxTextView +import com.duckduckgo.duckchat.impl.R + +class ChatHistorySelectAllViewHolder( + itemView: View, +) : RecyclerView.ViewHolder(itemView) { + + private val label: DaxTextView = itemView.findViewById(R.id.chatHistorySelectAllLabel) + + fun bind(allSelected: Boolean, onClick: () -> Unit) { + itemView.isSelected = allSelected + label.setText( + if (allSelected) R.string.duck_ai_chat_history_unselect_all else R.string.duck_ai_chat_history_select_all, + ) + itemView.setOnClickListener { onClick() } + } + + companion object { + fun create(parent: ViewGroup): ChatHistorySelectAllViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.view_chat_history_select_all, parent, false) + return ChatHistorySelectAllViewHolder(view) + } + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryUiState.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryUiState.kt index 67cce811becf..31ee594f8575 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryUiState.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryUiState.kt @@ -34,7 +34,13 @@ sealed interface ChatHistoryUiState { } sealed interface PendingConfirmation { - data object FireAll : PendingConfirmation - data class DeleteSelected(val count: Int) : PendingConfirmation + val chatIds: Set + val count: Int get() = chatIds.size + + /** Fire-all confirmation — captured Recent chat IDs at the moment Fire-all was tapped. */ + data class FireAll(override val chatIds: Set) : PendingConfirmation + + /** Delete-selected confirmation — captured selection snapshot. */ + data class DeleteSelected(override val chatIds: Set) : PendingConfirmation } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryViewHolder.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryViewHolder.kt index 27e4483c3c5a..0d6235865175 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryViewHolder.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryViewHolder.kt @@ -19,6 +19,9 @@ package com.duckduckgo.duckchat.impl.history import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.animation.AccelerateInterpolator +import android.view.animation.DecelerateInterpolator +import android.widget.FrameLayout import android.widget.ImageView import androidx.recyclerview.widget.RecyclerView import com.duckduckgo.common.ui.view.text.DaxTextView @@ -28,23 +31,59 @@ class ChatHistoryViewHolder( itemView: View, ) : RecyclerView.ViewHolder(itemView) { + private val iconContainer: FrameLayout = itemView.findViewById(R.id.chatHistoryIconContainer) private val typeIcon: ImageView = itemView.findViewById(R.id.chatHistoryTypeIcon) private val title: DaxTextView = itemView.findViewById(R.id.chatHistoryTitle) private val moreButton: ImageView = itemView.findViewById(R.id.chatHistoryMore) fun bind( item: ChatHistoryItem, + selected: Boolean, onClick: (ChatHistoryItem) -> Unit, onMoreClick: (ChatHistoryItem, View) -> Unit, onLongClick: (ChatHistoryItem) -> Boolean = { false }, ) { + iconContainer.animate().cancel() + iconContainer.rotationY = 0f title.text = item.displayTitle - typeIcon.setImageResource(iconFor(item.type, item.pinned)) + applySelectionState(item, selected) itemView.setOnClickListener { onClick(item) } itemView.setOnLongClickListener { onLongClick(item) } moreButton.setOnClickListener { anchor -> onMoreClick(item, anchor) } } + fun animateSelectionChange(item: ChatHistoryItem, selected: Boolean) { + iconContainer.animate().cancel() + iconContainer.animate() + .rotationY(HALF_FLIP_DEGREES) + .setDuration(HALF_FLIP_DURATION_MS) + .setInterpolator(AccelerateInterpolator()) + .withEndAction { + applySelectionState(item, selected) + iconContainer.rotationY = -HALF_FLIP_DEGREES + iconContainer.animate() + .rotationY(0f) + .setDuration(HALF_FLIP_DURATION_MS) + .setInterpolator(DecelerateInterpolator()) + .start() + } + .start() + } + + private fun applySelectionState(item: ChatHistoryItem, selected: Boolean) { + if (selected) { + iconContainer.setBackgroundResource(R.drawable.bg_chat_history_circle_accent) + typeIcon.setImageResource(com.duckduckgo.mobile.android.R.drawable.ic_check_24) + typeIcon.setColorFilter(itemView.context.getColor(com.duckduckgo.mobile.android.R.color.white)) + iconContainer.contentDescription = itemView.context.getString(R.string.duck_ai_chat_history_row_selected_content_description) + } else { + iconContainer.setBackgroundResource(R.drawable.bg_chat_history_circle_solid) + typeIcon.setImageResource(iconFor(item.type, item.pinned)) + typeIcon.colorFilter = null + iconContainer.contentDescription = null + } + } + private fun iconFor(type: ChatType, pinned: Boolean): Int = when (type) { ChatType.Discussion -> if (pinned) R.drawable.ic_chat_pin_24 else R.drawable.ic_chat_24 ChatType.ImageGeneration -> if (pinned) R.drawable.ic_images_pin_24 else R.drawable.ic_images_24 @@ -52,6 +91,9 @@ class ChatHistoryViewHolder( } companion object { + private const val HALF_FLIP_DEGREES = 90f + private const val HALF_FLIP_DURATION_MS = 150L + fun create(parent: ViewGroup): ChatHistoryViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.view_chat_history_item, parent, false) 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 5fde24abd7e0..fc9f9f9b75ff 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 @@ -19,28 +19,43 @@ package com.duckduckgo.duckchat.impl.history import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.dataclearing.api.plugin.ClearableData +import com.duckduckgo.dataclearing.api.plugin.DataClearingTrigger import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.duckchat.api.DuckAiFeatureState +import com.duckduckgo.duckchat.impl.DuckChatInternal import com.duckduckgo.duckchat.impl.history.ChatHistoryUiState.Loaded import com.duckduckgo.duckchat.impl.history.ChatHistoryUiState.Mode +import com.duckduckgo.duckchat.impl.history.ChatHistoryUiState.PendingConfirmation +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import logcat.logcat +import kotlinx.coroutines.launch import javax.inject.Inject @ContributesViewModel(FragmentScope::class) class ChatHistoryViewModel @Inject constructor( private val chatHistoryRepository: ChatHistoryRepository, + @AppCoroutineScope private val appScope: CoroutineScope, + private val duckChat: DuckChatInternal, + private val dataClearingTrigger: DataClearingTrigger, + private val duckAiFeatureState: DuckAiFeatureState, ) : ViewModel() { - private val searchState = MutableStateFlow(SearchState()) + private val controls = MutableStateFlow(UiControls()) + + /** Cached snapshot so non-suspend action methods can read Recent without re-subscribing. */ + private var latestItems: List = emptyList() val uiState: StateFlow = combine( - chatHistoryRepository.observeChats(), - searchState, + chatHistoryRepository.observeChats().onEach { latestItems = it }, + controls, ::reduce, ).stateIn( scope = viewModelScope, @@ -48,31 +63,169 @@ class ChatHistoryViewModel @Inject constructor( initialValue = ChatHistoryUiState.Loading, ) + fun isSelectMode(): Boolean = controls.value.mode is Mode.Selecting + + fun onChatRowClicked(chatId: String) { + if (controls.value.mode is Mode.Selecting) { + onSelectionToggled(chatId) + } else { + duckChat.openWithChatId(chatId) + } + } + + /** Long-press enters select mode with the row pre-selected; returns true to consume the event. */ + fun onChatRowLongClicked(chatId: String): Boolean { + controls.update { c -> + val nextMode = when (val mode = c.mode) { + is Mode.Selecting -> Mode.Selecting(toggle(mode.selectedChatIds, chatId)) + Mode.Default -> Mode.Selecting(setOf(chatId)) + } + c.copy(mode = nextMode) + } + return true + } + + fun onOpenDuckAiClicked() { + duckChat.openDuckChat() + } + + fun onFireIconClicked() { + if (controls.value.mode is Mode.Selecting) { + onDeleteSelectedRequested() + } else { + onFireAllRequested() + } + } + fun onSearchActivated() { - searchState.update { it.copy(active = true) } + controls.update { it.copy(search = it.search.copy(active = true)) } } fun onSearchQueryChanged(query: String) { - searchState.update { it.copy(query = query) } + controls.update { it.copy(search = it.search.copy(query = query)) } } fun onSearchClosed() { - searchState.value = SearchState() + controls.update { it.copy(search = SearchState()) } + } + + /** Fire-all wipes every Duck.ai chat including Pinned — always confirms via dialog before deleting. */ + fun onFireAllRequested() { + val all = latestItems + if (all.isEmpty()) return + controls.update { + it.copy(confirmation = PendingConfirmation.FireAll(chatIds = all.mapTo(mutableSetOf()) { i -> i.chatId })) + } + } + + /** Per-row overflow Delete — fires immediately, no confirmation. */ + fun onDeleteSingleChat(chatId: String) { + dispatchSelectedClear(setOf(chatId)) + } + + private fun dispatchSelectedClear(chatIds: Set) { + if (chatIds.isEmpty()) return + if (!duckAiFeatureState.showClearDuckAIChatHistory.value) return + val urls = chatIds.mapTo(mutableSetOf()) { duckChat.buildChatUrl(it) } + appScope.launch { + dataClearingTrigger.clearData(setOf(ClearableData.DuckChats.Selected(urls))) + } + } + + /** The dialog drives the actual deletion via the URL set surfaced by [chatUrlsForDialog]. */ + fun onFireAllConfirmed() { + controls.update { it.copy(confirmation = null) } } - private fun reduce(items: List, search: SearchState): ChatHistoryUiState { - logcat { "ChatHistory: reduce ${items.size} item(s), searchActive=${search.active}" } + fun onConfirmationCancelled() { + controls.update { it.copy(confirmation = null) } + } + + fun onEnterSelectMode() { + controls.update { it.copy(mode = Mode.Selecting(emptySet())) } + } + + fun onSelectionToggled(chatId: String) { + controls.update { c -> + val mode = c.mode as? Mode.Selecting ?: return@update c + c.copy(mode = Mode.Selecting(toggle(mode.selectedChatIds, chatId))) + } + } + + fun onSelectAllToggled() { + controls.update { c -> + val mode = c.mode as? Mode.Selecting ?: return@update c + val visibleIds = visibleChatIds(c.search) + // Filter to live ids — selection can lag deletes and skew the comparison. + val effectiveSelected = mode.selectedChatIds intersect latestItems.mapTo(mutableSetOf()) { it.chatId } + val next = if (effectiveSelected == visibleIds) emptySet() else visibleIds + c.copy(mode = Mode.Selecting(next)) + } + } + + fun onSelectModeCancelled() { + controls.update { it.copy(mode = Mode.Default) } + } + + fun onDeleteSelectedRequested() { + val current = controls.value.mode as? Mode.Selecting ?: return + val ids = current.selectedChatIds + when { + ids.isEmpty() -> Unit + ids.size == 1 -> { + controls.update { it.copy(mode = Mode.Default) } + dispatchSelectedClear(ids) + } + else -> controls.update { + it.copy(confirmation = PendingConfirmation.DeleteSelected(chatIds = ids)) + } + } + } + + /** + * The dialog drives the actual deletion via the URL set surfaced by [chatUrlsForDialog]. + * Both fields update atomically (one frame, not two) — keeps the test contract simple. + */ + fun onDeleteSelectedConfirmed() { + controls.update { it.copy(confirmation = null, mode = Mode.Default) } + } + + /** Snapshot of the captured chat IDs (resolved to URLs) for the pending confirmation. */ + fun chatUrlsForDialog(): Set? { + val ids = controls.value.confirmation?.chatIds ?: return null + if (ids.isEmpty()) return null + return ids.mapTo(mutableSetOf()) { duckChat.buildChatUrl(it) } + } + + private fun visibleChatIds(search: SearchState): Set = + latestItems + .asSequence() + .filter { item -> !search.active || search.query.isEmpty() || item.displayTitle.contains(search.query, ignoreCase = true) } + .mapTo(mutableSetOf()) { it.chatId } + + private fun reduce( + items: List, + controls: UiControls, + ): ChatHistoryUiState { if (items.isEmpty()) return ChatHistoryUiState.Empty val (pinned, recent) = items.partition { it.pinned } + val effectiveMode = when (val mode = controls.mode) { + is Mode.Selecting -> Mode.Selecting(mode.selectedChatIds intersect items.mapTo(mutableSetOf()) { it.chatId }) + Mode.Default -> Mode.Default + } return Loaded( - pinned = pinned.sortedByDate().filterBy(search), - recent = recent.sortedByDate().filterBy(search), - searchQuery = search.query, - searchActive = search.active, - mode = Mode.Default, + pinned = pinned.sortedByDate().filterBy(controls.search), + recent = recent.sortedByDate().filterBy(controls.search), + searchQuery = controls.search.query, + searchActive = controls.search.active, + mode = effectiveMode, + confirmation = controls.confirmation, ) } + private fun toggle(current: Set, chatId: String): Set = + if (chatId in current) current - chatId else current + chatId + private fun List.filterBy(search: SearchState): List = if (!search.active || search.query.isEmpty()) { this @@ -83,6 +236,12 @@ class ChatHistoryViewModel @Inject constructor( private fun List.sortedByDate(): List = sortedByDescending { it.lastEditMillis } + private data class UiControls( + val search: SearchState = SearchState(), + val confirmation: PendingConfirmation? = null, + val mode: Mode = Mode.Default, + ) + private data class SearchState( val active: Boolean = false, val query: String = "", diff --git a/duckchat/duckchat-impl/src/main/res/drawable/bg_chat_history_circle_accent.xml b/duckchat/duckchat-impl/src/main/res/drawable/bg_chat_history_circle_accent.xml new file mode 100644 index 000000000000..154beec21816 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/drawable/bg_chat_history_circle_accent.xml @@ -0,0 +1,5 @@ + + + + diff --git a/duckchat/duckchat-impl/src/main/res/drawable/ic_chat_history_unchecked_24.xml b/duckchat/duckchat-impl/src/main/res/drawable/ic_chat_history_unchecked_24.xml new file mode 100644 index 000000000000..d41dc4509d0f --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/drawable/ic_chat_history_unchecked_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/duckchat/duckchat-impl/src/main/res/drawable/selector_chat_history_select_all_indicator.xml b/duckchat/duckchat-impl/src/main/res/drawable/selector_chat_history_select_all_indicator.xml new file mode 100644 index 000000000000..60738c892459 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/drawable/selector_chat_history_select_all_indicator.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/duckchat/duckchat-impl/src/main/res/layout/view_chat_history_select_all.xml b/duckchat/duckchat-impl/src/main/res/layout/view_chat_history_select_all.xml new file mode 100644 index 000000000000..382c0056565a --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/layout/view_chat_history_select_all.xml @@ -0,0 +1,37 @@ + + + + + + + + diff --git a/duckchat/duckchat-impl/src/main/res/menu/menu_chat_history_default.xml b/duckchat/duckchat-impl/src/main/res/menu/menu_chat_history_default.xml index dd6f6bc55a0d..df15a2e62590 100644 --- a/duckchat/duckchat-impl/src/main/res/menu/menu_chat_history_default.xml +++ b/duckchat/duckchat-impl/src/main/res/menu/menu_chat_history_default.xml @@ -20,7 +20,7 @@ Search chats Close search Clear search + Delete chats + Select all + Unselect all + Selected + Not selected + Exit selection mode diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/clearing/DuckChatDataClearingPluginTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/clearing/DuckChatDataClearingPluginTest.kt index 0c08e6c4c393..b3bbce318054 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/clearing/DuckChatDataClearingPluginTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/clearing/DuckChatDataClearingPluginTest.kt @@ -198,4 +198,57 @@ class DuckChatDataClearingPluginTest { verify(duckChatDeleter, never()).deleteAllChats() verify(duckChatDeleter, never()).deleteChat(any()) } + + @Test + fun `DuckChats Selected deletes each chat in the set and records per-chat sync deletions`() = runTest { + whenever(duckChat.isDuckChatUrl(any())).thenReturn(true) + whenever(duckChat.isDuckChatUrl(eq(Uri.parse("https://duck.ai?chatID=alpha")))).thenReturn(true) + whenever(duckChat.isDuckChatUrl(eq(Uri.parse("https://duck.ai?chatID=beta")))).thenReturn(true) + whenever(duckChatDeleter.deleteChat("alpha")).thenReturn(true) + whenever(duckChatDeleter.deleteChat("beta")).thenReturn(true) + + plugin.onClearData( + setOf(ClearableData.DuckChats.Selected(setOf("https://duck.ai?chatID=alpha", "https://duck.ai?chatID=beta"))), + ) + + verify(duckChatDeleter).deleteChat("alpha") + verify(duckChatDeleter).deleteChat("beta") + verify(duckChatSyncRepository).recordSingleChatDeletion("alpha") + verify(duckChatSyncRepository).recordSingleChatDeletion("beta") + // Batched sync trigger — one event for the whole subset, not one per chat. + verify(syncEngine, org.mockito.kotlin.times(1)).triggerSync(any()) + } + + @Test + fun `DuckChats Selected does not call deleteAllChats`() = runTest { + whenever(duckChat.isDuckChatUrl(any())).thenReturn(true) + whenever(duckChatDeleter.deleteChat(any())).thenReturn(true) + + plugin.onClearData(setOf(ClearableData.DuckChats.Selected(setOf("https://duck.ai?chatID=x")))) + + verify(duckChatDeleter, never()).deleteAllChats() + } + + @Test + fun `DuckChats Selected with empty url set is a no-op`() = runTest { + plugin.onClearData(setOf(ClearableData.DuckChats.Selected(emptySet()))) + + verify(duckChatDeleter, never()).deleteChat(any()) + verify(duckChatSyncRepository, never()).recordSingleChatDeletion(any()) + verify(syncEngine, never()).triggerSync(any()) + } + + @Test + fun `DuckChats Selected skips urls that are not duck chat urls`() = runTest { + whenever(duckChat.isDuckChatUrl(eq(Uri.parse("https://duck.ai?chatID=alpha")))).thenReturn(true) + whenever(duckChat.isDuckChatUrl(eq(Uri.parse("https://example.com")))).thenReturn(false) + whenever(duckChatDeleter.deleteChat("alpha")).thenReturn(true) + + plugin.onClearData( + setOf(ClearableData.DuckChats.Selected(setOf("https://duck.ai?chatID=alpha", "https://example.com"))), + ) + + verify(duckChatDeleter).deleteChat("alpha") + verify(duckChatDeleter, never()).deleteChat(eq("")) + } } 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 e1b9d076b091..bde85aebf831 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 @@ -19,7 +19,11 @@ package com.duckduckgo.duckchat.impl.history import app.cash.turbine.TurbineTestContext import app.cash.turbine.test import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.dataclearing.api.plugin.ClearableData +import com.duckduckgo.dataclearing.api.plugin.DataClearingTrigger +import com.duckduckgo.duckchat.api.DuckAiFeatureState import com.duckduckgo.duckchat.impl.history.ChatHistoryUiState.Loaded +import com.duckduckgo.duckchat.impl.messaging.fakes.FakeDuckChatInternal import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest @@ -27,6 +31,8 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever class ChatHistoryViewModelTest { @@ -35,7 +41,13 @@ class ChatHistoryViewModelTest { private val source = MutableStateFlow>(emptyList()) private val repository = FakeChatHistoryRepository(source) - private val viewModel = ChatHistoryViewModel(repository) + private val duckChat = FakeDuckChatInternal() + private val dataClearingTrigger = RecordingDataClearingTrigger() + private val showClearDuckAIChatHistoryFlow = MutableStateFlow(true) + private val duckAiFeatureState: DuckAiFeatureState = mock { + whenever(it.showClearDuckAIChatHistory).thenReturn(showClearDuckAIChatHistoryFlow) + } + private val viewModel = ChatHistoryViewModel(repository, coroutineRule.testScope, duckChat, dataClearingTrigger, duckAiFeatureState) @Test fun `initial state is Loading`() = coroutineRule.testScope.runTest { @@ -209,6 +221,590 @@ class ChatHistoryViewModelTest { } } + // --- Fire-all --- + + @Test + fun `onFireAllRequested with two or more chats sets FireAll confirmation with every chatId including pinned`() = runTest { + source.value = listOf( + item("p", pinned = true), + item("r1"), + item("r2"), + item("r3"), + ) + + viewModel.uiState.test { + awaitInitialLoaded() + + viewModel.onFireAllRequested() + + val confirming = awaitItem() as Loaded + assertEquals( + ChatHistoryUiState.PendingConfirmation.FireAll(chatIds = setOf("p", "r1", "r2", "r3")), + confirming.confirmation, + ) + assertTrue(repository.deletedChatIds.isEmpty()) + } + } + + @Test + fun `onFireAllRequested with exactly one recent chat sets FireAll confirmation`() = runTest { + source.value = listOf(item("r1")) + + viewModel.uiState.test { + awaitInitialLoaded() + + viewModel.onFireAllRequested() + + val confirming = awaitItem() as Loaded + assertEquals( + ChatHistoryUiState.PendingConfirmation.FireAll(chatIds = setOf("r1")), + confirming.confirmation, + ) + } + assertTrue(dataClearingTrigger.calls.isEmpty()) + assertTrue(repository.deletedChatIds.isEmpty()) + } + + @Test + fun `onFireAllRequested with exactly one pinned chat sets FireAll confirmation`() = runTest { + source.value = listOf(item("p", pinned = true)) + + viewModel.uiState.test { + awaitInitialLoaded() + + viewModel.onFireAllRequested() + + val confirming = awaitItem() as Loaded + assertEquals( + ChatHistoryUiState.PendingConfirmation.FireAll(chatIds = setOf("p")), + confirming.confirmation, + ) + } + assertTrue(dataClearingTrigger.calls.isEmpty()) + assertTrue(repository.deletedChatIds.isEmpty()) + } + + @Test + fun `onFireAllRequested with only pinned chats and no recent sets FireAll confirmation with the pinned ids`() = runTest { + source.value = listOf( + item("p1", pinned = true), + item("p2", pinned = true), + ) + + viewModel.uiState.test { + awaitInitialLoaded() + + viewModel.onFireAllRequested() + + val confirming = awaitItem() as Loaded + assertEquals( + ChatHistoryUiState.PendingConfirmation.FireAll(chatIds = setOf("p1", "p2")), + confirming.confirmation, + ) + assertTrue(repository.deletedChatIds.isEmpty()) + } + } + + @Test + fun `onFireAllConfirmed only clears the confirmation state — dialog options path handles deletion`() = runTest { + source.value = listOf( + item("p1", pinned = true), + item("p2", pinned = true), + item("r1"), + item("r2"), + item("r3"), + ) + + viewModel.uiState.test { + awaitInitialLoaded() + + viewModel.onFireAllRequested() + awaitItem() // confirmation = FireAll(5) — p1, p2, r1, r2, r3 + + viewModel.onFireAllConfirmed() + + // ViewModel doesn't touch the repository — the dialog drives the deletion. + val cleared = awaitItem() as Loaded + assertEquals(null, cleared.confirmation) + assertTrue(repository.deletedChatIds.isEmpty()) + assertEquals(listOf("p1", "p2"), cleared.pinned.map { it.chatId }) + assertEquals(listOf("r1", "r2", "r3"), cleared.recent.map { it.chatId }) + } + } + + @Test + fun `onConfirmationCancelled clears the FireAll confirmation without deleting`() = runTest { + source.value = listOf( + item("r1"), + item("r2"), + ) + + viewModel.uiState.test { + awaitInitialLoaded() + + viewModel.onFireAllRequested() + val confirming = awaitItem() as Loaded + assertEquals(ChatHistoryUiState.PendingConfirmation.FireAll(chatIds = setOf("r1", "r2")), confirming.confirmation) + + viewModel.onConfirmationCancelled() + + val cancelled = awaitItem() as Loaded + assertEquals(null, cancelled.confirmation) + assertTrue(repository.deletedChatIds.isEmpty()) + assertEquals(listOf("r1", "r2"), cancelled.recent.map { it.chatId }) + } + } + + @Test + fun `onFireAllConfirmed does not call the repository directly — dialog drives the deletion`() = runTest { + source.value = listOf( + item("p", pinned = true), + item("r1"), + item("r2"), + ) + + viewModel.uiState.test { + awaitInitialLoaded() + + viewModel.onFireAllRequested() + awaitItem() // confirmation = FireAll(3) — p, r1, r2 + viewModel.onFireAllConfirmed() + awaitItem() // confirmation cleared + } + + // ViewModel never touches the repository on the dialog path — production wipes every chat + // including Pinned via the dialog-driven Selected dispatch. + assertEquals(false, repository.deleteAllChatsCalled) + assertTrue(repository.deletedChatIds.isEmpty()) + } + + // --- Chat resume / Duck.ai open --- + + @Test + fun `onChatRowClicked in default mode resumes the chat in DuckAi`() = runTest { + viewModel.onChatRowClicked("abc") + + assertEquals(listOf("abc"), duckChat.openWithChatIdCalls) + } + + @Test + fun `onChatRowLongClicked in default mode enters select mode with the row pre-selected`() = runTest { + source.value = listOf(item("a"), item("b")) + + viewModel.uiState.test { + awaitInitialLoaded() + + val consumed = viewModel.onChatRowLongClicked("a") + + assertTrue(consumed) + val loaded = awaitItem() as Loaded + val mode = loaded.mode as ChatHistoryUiState.Mode.Selecting + assertEquals(setOf("a"), mode.selectedChatIds) + assertTrue(duckChat.openWithChatIdCalls.isEmpty()) + } + } + + @Test + fun `onChatRowLongClicked in select mode toggles the row like a tap`() = runTest { + source.value = listOf(item("a"), item("b")) + + viewModel.uiState.test { + awaitInitialLoaded() + viewModel.onChatRowLongClicked("a") + awaitItem() // Selecting({a}) + + val consumed = viewModel.onChatRowLongClicked("b") + + assertTrue(consumed) + val loaded = awaitItem() as Loaded + val mode = loaded.mode as ChatHistoryUiState.Mode.Selecting + assertEquals(setOf("a", "b"), mode.selectedChatIds) + } + } + + @Test + fun `onChatRowClicked in select mode toggles selection instead of opening DuckAi`() = runTest { + source.value = listOf(item("a")) + viewModel.uiState.test { + awaitInitialLoaded() + viewModel.onEnterSelectMode() + awaitItem() + + viewModel.onChatRowClicked("a") + + val loaded = awaitItem() as Loaded + val mode = loaded.mode as ChatHistoryUiState.Mode.Selecting + assertEquals(setOf("a"), mode.selectedChatIds) + assertTrue(duckChat.openWithChatIdCalls.isEmpty()) + } + } + + @Test + fun `onOpenDuckAiClicked delegates to DuckChat`() = runTest { + viewModel.onOpenDuckAiClicked() + + assertEquals(1, duckChat.openDuckChatCalls) + } + + @Test + fun `onFireIconClicked in default mode triggers Fire-all`() = runTest { + source.value = listOf(item("r1"), item("r2")) + viewModel.uiState.test { + awaitInitialLoaded() + + viewModel.onFireIconClicked() + + val confirming = awaitItem() as Loaded + assertEquals(ChatHistoryUiState.PendingConfirmation.FireAll(chatIds = setOf("r1", "r2")), confirming.confirmation) + } + } + + @Test + fun `onFireIconClicked in select mode triggers Delete-selected`() = runTest { + source.value = listOf(item("a"), item("b"), item("c")) + viewModel.uiState.test { + awaitInitialLoaded() + viewModel.onEnterSelectMode() + awaitItem() + viewModel.onSelectionToggled("a") + awaitItem() + viewModel.onSelectionToggled("b") + awaitItem() + + viewModel.onFireIconClicked() + + val confirming = awaitItem() as Loaded + val confirmation = confirming.confirmation as ChatHistoryUiState.PendingConfirmation.DeleteSelected + assertEquals(setOf("a", "b"), confirmation.chatIds) + } + } + + // --- Select-mode --- + + @Test + fun `onEnterSelectMode transitions to Selecting with empty selection`() = runTest { + source.value = listOf(item("a"), item("b")) + + viewModel.uiState.test { + awaitInitialLoaded() + + viewModel.onEnterSelectMode() + + val loaded = awaitItem() as Loaded + val mode = loaded.mode as ChatHistoryUiState.Mode.Selecting + assertEquals(emptySet(), mode.selectedChatIds) + } + } + + @Test + fun `onSelectionToggled adds and removes ids and empty selection stays in select mode`() = runTest { + source.value = listOf(item("a"), item("b")) + + viewModel.uiState.test { + awaitInitialLoaded() + viewModel.onEnterSelectMode() + awaitItem() // Selecting({}) + + viewModel.onSelectionToggled("a") + val afterAdd = awaitItem() as Loaded + assertEquals(setOf("a"), (afterAdd.mode as ChatHistoryUiState.Mode.Selecting).selectedChatIds) + + viewModel.onSelectionToggled("a") + val afterRemove = awaitItem() as Loaded + val mode = afterRemove.mode as ChatHistoryUiState.Mode.Selecting + assertEquals(emptySet(), mode.selectedChatIds) + } + } + + @Test + fun `onSelectAllToggled fills the selection with every visible chat id`() = runTest { + source.value = listOf(item("p", pinned = true), item("a"), item("b")) + + viewModel.uiState.test { + awaitInitialLoaded() + viewModel.onEnterSelectMode() + awaitItem() // Selecting({}) + + viewModel.onSelectAllToggled() + + val loaded = awaitItem() as Loaded + val mode = loaded.mode as ChatHistoryUiState.Mode.Selecting + assertEquals(setOf("p", "a", "b"), mode.selectedChatIds) + } + } + + @Test + fun `onSelectAllToggled with everything selected clears the selection but stays in select mode`() = runTest { + source.value = listOf(item("a"), item("b")) + + viewModel.uiState.test { + awaitInitialLoaded() + viewModel.onEnterSelectMode() + awaitItem() // Selecting({}) + viewModel.onSelectAllToggled() + awaitItem() // Selecting({a, b}) + + viewModel.onSelectAllToggled() + + val cleared = awaitItem() as Loaded + val mode = cleared.mode as ChatHistoryUiState.Mode.Selecting + assertEquals(emptySet(), mode.selectedChatIds) + } + } + + @Test + fun `when an item disappears from the source the selection drops the stale id in the next state`() = runTest { + source.value = listOf(item("a"), item("b"), item("c")) + + viewModel.uiState.test { + awaitInitialLoaded() + viewModel.onEnterSelectMode() + awaitItem() // Selecting({}) + viewModel.onSelectionToggled("a") + awaitItem() // Selecting({a}) + viewModel.onSelectionToggled("b") + awaitItem() // Selecting({a, b}) + + source.value = listOf(item("a"), item("c")) + + val updated = awaitItem() as Loaded + val mode = updated.mode as ChatHistoryUiState.Mode.Selecting + assertEquals(setOf("a"), mode.selectedChatIds) + } + } + + @Test + fun `onSelectAllToggled with a stale selection equal to visible still toggles off`() = runTest { + source.value = listOf(item("a"), item("b"), item("c")) + + viewModel.uiState.test { + awaitInitialLoaded() + viewModel.onEnterSelectMode() + awaitItem() // Selecting({}) + viewModel.onSelectAllToggled() + awaitItem() // Selecting({a, b, c}) + + source.value = listOf(item("a"), item("b")) + awaitItem() // mode reconciled to Selecting({a, b}) by reduce + + viewModel.onSelectAllToggled() + + val cleared = awaitItem() as Loaded + val mode = cleared.mode as ChatHistoryUiState.Mode.Selecting + assertEquals(emptySet(), mode.selectedChatIds) + } + } + + @Test + fun `onSelectModeCancelled returns to Default mode with no deletion`() = runTest { + source.value = listOf(item("a"), item("b")) + + viewModel.uiState.test { + awaitInitialLoaded() + viewModel.onEnterSelectMode() + awaitItem() + viewModel.onSelectionToggled("a") + awaitItem() + + viewModel.onSelectModeCancelled() + + val cancelled = awaitItem() as Loaded + assertEquals(ChatHistoryUiState.Mode.Default, cancelled.mode) + assertTrue(repository.deletedChatIds.isEmpty()) + } + } + + @Test + fun `onDeleteSelectedRequested with empty selection is a no-op`() = runTest { + source.value = listOf(item("a")) + + viewModel.uiState.test { + awaitInitialLoaded() + viewModel.onEnterSelectMode() + awaitItem() // Selecting({}) + + viewModel.onDeleteSelectedRequested() + + expectNoEvents() + assertTrue(repository.deletedChatIds.isEmpty()) + } + } + + @Test + fun `onDeleteSelectedRequested with one selected dispatches DuckChats Selected and exits select mode`() = runTest { + source.value = listOf(item("a"), item("b")) + + viewModel.uiState.test { + awaitInitialLoaded() + viewModel.onEnterSelectMode() + awaitItem() + viewModel.onSelectionToggled("a") + awaitItem() + + viewModel.onDeleteSelectedRequested() + + val final = awaitItem() as Loaded + assertEquals(ChatHistoryUiState.Mode.Default, final.mode) + } + assertEquals( + listOf(setOf(ClearableData.DuckChats.Selected(setOf("https://duck.ai?chatID=a")))), + dataClearingTrigger.calls, + ) + assertTrue(repository.deletedChatIds.isEmpty()) + } + + @Test + fun `onDeleteSelectedRequested with two or more sets DeleteSelected with the captured ids`() = runTest { + source.value = listOf(item("a"), item("b"), item("c")) + + viewModel.uiState.test { + awaitInitialLoaded() + viewModel.onEnterSelectMode() + awaitItem() + viewModel.onSelectionToggled("a") + awaitItem() + viewModel.onSelectionToggled("b") + awaitItem() + + viewModel.onDeleteSelectedRequested() + + val confirming = awaitItem() as Loaded + val confirmation = confirming.confirmation as ChatHistoryUiState.PendingConfirmation.DeleteSelected + assertEquals(setOf("a", "b"), confirmation.chatIds) + assertEquals(2, confirmation.count) + assertTrue(repository.deletedChatIds.isEmpty()) + } + } + + @Test + fun `onDeleteSelectedConfirmed clears confirmation and exits select mode without dispatching`() = runTest { + source.value = listOf(item("p", pinned = true), item("a"), item("b"), item("c")) + + viewModel.uiState.test { + awaitInitialLoaded() + viewModel.onEnterSelectMode() + awaitItem() + viewModel.onSelectionToggled("a") + awaitItem() + viewModel.onSelectionToggled("c") + awaitItem() + viewModel.onDeleteSelectedRequested() + awaitItem() // DeleteSelected({a, c}) + + viewModel.onDeleteSelectedConfirmed() + val final = awaitItem() as Loaded + assertEquals(ChatHistoryUiState.Mode.Default, final.mode) + assertEquals(null, final.confirmation) + } + // ViewModel must not dispatch — the dialog drives the clear via selectedChatUrls. + assertTrue(dataClearingTrigger.calls.isEmpty()) + assertTrue(repository.deletedChatIds.isEmpty()) + } + + @Test + fun `chatUrlsForDialog returns the captured chatIds mapped through DuckChat buildChatUrl`() = runTest { + source.value = listOf(item("a"), item("b"), item("c")) + + viewModel.uiState.test { + awaitInitialLoaded() + viewModel.onEnterSelectMode() + awaitItem() + viewModel.onSelectionToggled("a") + awaitItem() + viewModel.onSelectionToggled("c") + awaitItem() + viewModel.onDeleteSelectedRequested() + awaitItem() // DeleteSelected({a, c}) + + assertEquals( + setOf("https://duck.ai?chatID=a", "https://duck.ai?chatID=c"), + viewModel.chatUrlsForDialog(), + ) + } + } + + @Test + fun `chatUrlsForDialog returns null when no confirmation is pending`() = runTest { + source.value = listOf(item("a")) + + viewModel.uiState.test { + awaitInitialLoaded() + + assertEquals(null, viewModel.chatUrlsForDialog()) + } + } + + @Test + fun `chatUrlsForDialog returns every chat URL including pinned while a FireAll confirmation is pending`() = runTest { + source.value = listOf(item("p", pinned = true), item("r1"), item("r2")) + + viewModel.uiState.test { + awaitInitialLoaded() + + viewModel.onFireAllRequested() + awaitItem() // FireAll({p, r1, r2}) + + assertEquals( + setOf("https://duck.ai?chatID=p", "https://duck.ai?chatID=r1", "https://duck.ai?chatID=r2"), + viewModel.chatUrlsForDialog(), + ) + } + } + + // --- Per-row overflow Delete --- + + @Test + fun `onDeleteSingleChat dispatches DuckChats Selected with that one chat url and no confirmation`() = runTest { + source.value = listOf(item("a"), item("b")) + + viewModel.uiState.test { + awaitInitialLoaded() + + viewModel.onDeleteSingleChat("a") + + expectNoEvents() // no confirmation state set + } + assertEquals( + listOf(setOf(ClearableData.DuckChats.Selected(setOf("https://duck.ai?chatID=a")))), + dataClearingTrigger.calls, + ) + assertTrue(repository.deletedChatIds.isEmpty()) + } + + @Test + fun `onDeleteSingleChat is a no-op when showClearDuckAIChatHistory is off`() = runTest { + source.value = listOf(item("a")) + showClearDuckAIChatHistoryFlow.value = false + + viewModel.uiState.test { + awaitInitialLoaded() + + viewModel.onDeleteSingleChat("a") + + expectNoEvents() + } + assertTrue(dataClearingTrigger.calls.isEmpty()) + } + + @Test + fun `onDeleteSelectedRequested with one selection is a no-op when showClearDuckAIChatHistory is off`() = runTest { + source.value = listOf(item("a")) + showClearDuckAIChatHistoryFlow.value = false + + viewModel.uiState.test { + awaitInitialLoaded() + viewModel.onEnterSelectMode() + awaitItem() // Selecting({}) + viewModel.onSelectionToggled("a") + awaitItem() // Selecting({a}) + + viewModel.onDeleteSelectedRequested() + + awaitItem() // mode reset to Default before dispatch (which is gated off) + } + assertTrue(dataClearingTrigger.calls.isEmpty()) + } + /** * `stateIn(WhileSubscribed)` does not guarantee subscribers observe the `Loading` initial * value — the upstream may emit before the StateFlow can replay it. Tolerate both orderings. @@ -238,9 +834,29 @@ class ChatHistoryViewModelTest { } private class FakeChatHistoryRepository( - private val source: Flow>, + private val source: MutableStateFlow>, ) : ChatHistoryRepository { + val deletedChatIds: MutableList = mutableListOf() + var deleteAllChatsCalled: Boolean = false + private set + override fun observeChats(): Flow> = source - override suspend fun deleteChat(chatId: String) = Unit - override suspend fun deleteAllChats() = Unit + + override suspend fun deleteChat(chatId: String) { + deletedChatIds += chatId + source.value = source.value.filterNot { it.chatId == chatId } + } + + override suspend fun deleteAllChats() { + deleteAllChatsCalled = true + source.value = emptyList() + } +} + +private class RecordingDataClearingTrigger : DataClearingTrigger { + val calls: MutableList> = mutableListOf() + + override suspend fun clearData(types: Set) { + calls += types + } } diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChatInternal.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChatInternal.kt index 7c0e5fbea8a6..0b720d588cb3 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChatInternal.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChatInternal.kt @@ -56,7 +56,13 @@ class FakeDuckChatInternal( // DuckChat interface methods override fun isEnabled(): Boolean = enabled - override fun openDuckChat() { } + val openWithChatIdCalls: MutableList = mutableListOf() + var openDuckChatCalls: Int = 0 + private set + + override fun openDuckChat() { + openDuckChatCalls += 1 + } override fun openDuckChatWithAutoPrompt(query: String) { } @@ -189,7 +195,11 @@ class FakeDuckChatInternal( override suspend fun isChatHistoryAvailable(): Boolean = false - override fun openWithChatId(chatId: String) { } + override fun openWithChatId(chatId: String) { + openWithChatIdCalls += chatId + } + + override fun buildChatUrl(chatId: String): String = "https://duck.ai?chatID=$chatId" private val _defaultTogglePosition = MutableStateFlow(null)