From 31a8ee8d45902f6e041644c16487de7f1f915d54 Mon Sep 17 00:00:00 2001 From: Karl Dimla Date: Wed, 9 Sep 2026 12:18:39 +0200 Subject: [PATCH 1/5] Add menu for chat history and select all functionality --- .../impl/history/ChatHistoryFragment.kt | 23 +++++++++++ .../main/res/drawable/ic_shield_check_24.xml | 9 +++++ .../layout/popup_chat_history_overflow.xml | 39 +++++++++++++++++++ .../res/menu/menu_chat_history_default.xml | 7 ++++ .../src/main/res/values/donottranslate.xml | 4 ++ 5 files changed, 82 insertions(+) create mode 100644 duckchat/duckchat-impl/src/main/res/drawable/ic_shield_check_24.xml create mode 100644 duckchat/duckchat-impl/src/main/res/layout/popup_chat_history_overflow.xml 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 ef06b06cbeff..9fc40b0b2955 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 @@ -92,6 +92,8 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { ViewModelProvider(this, viewModelFactory)[ChatHistoryViewModel::class.java] } + private var overflowMenuEnabled = false + private val adapter = ChatHistoryAdapter( onChatClicked = { item -> viewModel.onChatRowClicked(item.chatId) }, onChatMoreClicked = { item, anchor -> showRowPopup(item, anchor) }, @@ -133,6 +135,13 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { binding.toolbar.inflateMenu(R.menu.menu_chat_history_default) binding.toolbar.setOnMenuItemClickListener(::onMenuItemClicked) + viewLifecycleOwner.lifecycleScope.launch { + overflowMenuEnabled = withContext(dispatchers.io()) { duckChatFeature.nativeDuckAiSidebar().isEnabled() } + if (!viewModel.isSelectMode() && !binding.searchBar.isVisible) { + binding.toolbar.menu.findItem(R.id.chat_history_action_overflow)?.isVisible = overflowMenuEnabled + } + } + binding.chatHistoryList.layoutManager = LinearLayoutManager(requireContext()) binding.chatHistoryList.adapter = adapter @@ -287,6 +296,7 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { binding.toolbar.menu.findItem(R.id.chat_history_action_new)?.isVisible = true binding.toolbar.menu.findItem(R.id.chat_history_action_search)?.isVisible = true binding.toolbar.menu.findItem(R.id.chat_history_action_download_selected)?.isVisible = false + binding.toolbar.menu.findItem(R.id.chat_history_action_overflow)?.isVisible = overflowMenuEnabled } private fun applySelectModeToolbar(count: Int) { @@ -298,6 +308,7 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { binding.toolbar.menu.findItem(R.id.chat_history_action_new)?.isVisible = false binding.toolbar.menu.findItem(R.id.chat_history_action_search)?.isVisible = false binding.toolbar.menu.findItem(R.id.chat_history_action_download_selected)?.isVisible = true + binding.toolbar.menu.findItem(R.id.chat_history_action_overflow)?.isVisible = false } private fun buildEntries( @@ -360,6 +371,10 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { viewModel.onDownloadSelectedRequested() true } + R.id.chat_history_action_overflow -> { + binding.toolbar.findViewById(R.id.chat_history_action_overflow)?.let { showOverflowPopup(it) } + true + } else -> false } @@ -378,6 +393,14 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { // onBackPressedCallback.isEnabled is reset by render() — select mode may still be active. } + private fun showOverflowPopup(anchor: View) { + val popup = PopupMenu(layoutInflater, R.layout.popup_chat_history_overflow) + val view = popup.contentView + popup.onMenuItemClicked(view.findViewById(R.id.selectChats)) { viewModel.onEnterSelectMode() } + popup.onMenuItemClicked(view.findViewById(R.id.chatsProtection)) { } + popup.show(binding.root, anchor) + } + private fun showRowPopup(item: ChatHistoryItem, anchor: View) { viewLifecycleOwner.lifecycleScope.launch { val isRenameEnabled = withContext(dispatchers.io()) { duckChatFeature.renameChat().isEnabled() } diff --git a/duckchat/duckchat-impl/src/main/res/drawable/ic_shield_check_24.xml b/duckchat/duckchat-impl/src/main/res/drawable/ic_shield_check_24.xml new file mode 100644 index 000000000000..f1ad8db0de68 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/drawable/ic_shield_check_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/duckchat/duckchat-impl/src/main/res/layout/popup_chat_history_overflow.xml b/duckchat/duckchat-impl/src/main/res/layout/popup_chat_history_overflow.xml new file mode 100644 index 000000000000..e0cbc58746e3 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/layout/popup_chat_history_overflow.xml @@ -0,0 +1,39 @@ + + + + + + + + + + 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 7c1010ad4e5b..25a7805d0ab0 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 @@ -42,4 +42,11 @@ android:title="@string/duck_ai_chat_history_action_fire_content_description" app:showAsAction="always" /> + + diff --git a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml index 1b53bb52c004..b31fb8df69fb 100644 --- a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml +++ b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml @@ -39,4 +39,8 @@ Turn Duck.ai On Keep Duck.ai Off + + + Select Chats + Chats Protection From 3165abd5354eda138a7a9a8d1d0d665e71750a5c Mon Sep 17 00:00:00 2001 From: Karl Dimla Date: Thu, 10 Sep 2026 12:38:43 +0200 Subject: [PATCH 2/5] Add handling for Chat Protections --- .../com/duckduckgo/duckchat/impl/RealDuckChat.kt | 8 ++++++++ .../duckchat/impl/history/ChatHistoryFragment.kt | 10 +++++++++- .../impl/history/ChatHistoryViewModel.kt | 16 ++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) 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 0f1b2f096e83..17384e4afa24 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 @@ -188,6 +188,9 @@ interface DuckChatInternal : DuckChat { /** Single source of truth for the Duck.ai chat URL shape. */ fun buildChatUrl(chatId: String): String + /** Returns the Duck.ai URL that opens with the chat protections panel open (e.g. https://duck.ai/chat?chatProtection=open). */ + fun getChatProtectionUrl(): String + /** * Calls onClose when a close event is emitted. */ @@ -760,6 +763,9 @@ class RealDuckChat @Inject constructor( override fun getDuckChatSettingsUrl(): String = resolveDuckAiUrl(DUCK_CHAT_SETTINGS_WEB_LINK) + override fun getChatProtectionUrl(): String = + appendParameters(mapOf(CHAT_PROTECTION_QUERY_NAME to CHAT_PROTECTION_QUERY_VALUE), getDuckChatLink()) + private fun addChatParameters( query: String, autoPrompt: Boolean, @@ -1132,6 +1138,8 @@ class RealDuckChat @Inject constructor( private const val PROMPT_QUERY_VALUE = "1" private const val PLACEMENT_QUERY_NAME = "placement" private const val PLACEMENT_QUERY_VALUE = "sidebar" + private const val CHAT_PROTECTION_QUERY_NAME = "chatProtection" + private const val CHAT_PROTECTION_QUERY_VALUE = "open" private const val BANG_QUERY_NAME = "bang" private const val BANG_QUERY_VALUE = "true" private const val MODE_QUERY_NAME = "mode" 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 9fc40b0b2955..cf5fa39b095e 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 @@ -191,6 +191,14 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { when (event) { is ChatHistoryViewModel.NavigationEvent.OpenChat -> startActivity(browserNav.openInNewTab(requireContext(), event.url, event.sourceTabId)) + is ChatHistoryViewModel.NavigationEvent.OpenChatProtection -> + startActivity( + if (event.inNewTab) { + browserNav.openInNewTab(requireContext(), event.url, event.sourceTabId) + } else { + browserNav.openInCurrentTab(requireContext(), event.url) + }, + ) is ChatHistoryViewModel.NavigationEvent.OpenRename -> openRenameScreen(event.chatId, event.currentTitle) is ChatHistoryViewModel.NavigationEvent.ShowDownloadComplete -> showDownloadCompleteSnackbar(event.fileName) is ChatHistoryViewModel.NavigationEvent.ShowBulkDownloadComplete -> showBulkDownloadCompleteSnackbar(event.count) @@ -397,7 +405,7 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { val popup = PopupMenu(layoutInflater, R.layout.popup_chat_history_overflow) val view = popup.contentView popup.onMenuItemClicked(view.findViewById(R.id.selectChats)) { viewModel.onEnterSelectMode() } - popup.onMenuItemClicked(view.findViewById(R.id.chatsProtection)) { } + popup.onMenuItemClicked(view.findViewById(R.id.chatsProtection)) { viewModel.onChatsProtectionClicked() } popup.show(binding.root, anchor) } 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 dfce85a73f31..1b9f91334ad5 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 @@ -16,6 +16,7 @@ package com.duckduckgo.duckchat.impl.history +import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel @@ -287,6 +288,20 @@ class ChatHistoryViewModel @Inject constructor( controls.update { it.copy(mode = Mode.Selecting(emptySet())) } } + fun onChatsProtectionClicked() { + viewModelScope.launch { + val sourceTab = tabRepository.getSelectedTab() + val fromChatTab = sourceTab?.url?.let { duckChat.isDuckChatUrl(it.toUri()) } == true + navigationChannel.trySend( + NavigationEvent.OpenChatProtection( + url = duckChat.getChatProtectionUrl(), + sourceTabId = sourceTab?.tabId, + inNewTab = !fromChatTab, + ), + ) + } + } + fun onSelectionToggled(chatId: String) { controls.update { c -> val mode = c.mode as? Mode.Selecting ?: return@update c @@ -408,6 +423,7 @@ class ChatHistoryViewModel @Inject constructor( sealed interface NavigationEvent { data class OpenChat(val url: String, val sourceTabId: String?) : NavigationEvent + data class OpenChatProtection(val url: String, val sourceTabId: String?, val inNewTab: Boolean) : NavigationEvent data class OpenRename(val chatId: String, val currentTitle: String) : NavigationEvent data class ShowDownloadComplete(val fileName: String) : NavigationEvent data class ShowBulkDownloadComplete(val count: Int) : NavigationEvent From 1e45cd028bce308229e76395b357777f0bb204b2 Mon Sep 17 00:00:00 2001 From: Karl Dimla Date: Thu, 10 Sep 2026 13:34:17 +0200 Subject: [PATCH 3/5] Rename Chats Protection label to Chat Protection Co-Authored-By: Claude Opus 4.8 (1M context) --- duckchat/duckchat-impl/src/main/res/values/donottranslate.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml index b31fb8df69fb..2246c8474f1f 100644 --- a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml +++ b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml @@ -42,5 +42,5 @@ Select Chats - Chats Protection + Chat Protection From f45e5216bcc368b7e20cb4118e1df62607830ae3 Mon Sep 17 00:00:00 2001 From: Karl Dimla Date: Thu, 10 Sep 2026 13:38:51 +0200 Subject: [PATCH 4/5] Implement getChatProtectionUrl in FakeDuckChatInternal Fixes test module compilation broken when getChatProtectionUrl was added to the DuckChatInternal interface without updating the test fake. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt | 2 +- .../duckchat/impl/messaging/fakes/FakeDuckChatInternal.kt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) 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 17384e4afa24..7167a19566c9 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 @@ -764,7 +764,7 @@ class RealDuckChat @Inject constructor( override fun getDuckChatSettingsUrl(): String = resolveDuckAiUrl(DUCK_CHAT_SETTINGS_WEB_LINK) override fun getChatProtectionUrl(): String = - appendParameters(mapOf(CHAT_PROTECTION_QUERY_NAME to CHAT_PROTECTION_QUERY_VALUE), getDuckChatLink()) + appendParameters(mapOf(CHAT_PROTECTION_QUERY_NAME to CHAT_PROTECTION_QUERY_VALUE) + nativeChatInputParameters(), getDuckChatLink()) private fun addChatParameters( query: String, 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 f82ce04a7d44..184ef934f342 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 @@ -233,6 +233,8 @@ class FakeDuckChatInternal( override fun buildChatUrl(chatId: String): String = "https://duck.ai?chatID=$chatId" + override fun getChatProtectionUrl(): String = "https://duck.ai/chat?chatProtection=open" + private val _defaultTogglePosition = MutableStateFlow(null) override suspend fun setDefaultTogglePosition(position: DefaultTogglePosition) { From 1f2403357140f7b907d992c42ae8143c40098247 Mon Sep 17 00:00:00 2001 From: Karl Dimla Date: Thu, 10 Sep 2026 13:53:41 +0200 Subject: [PATCH 5/5] Hide Select Chats in overflow when history is empty reduce() stays on Empty when there are no chats, so entering select mode from the empty state was a no-op. Only offer Select Chats when chats are present; Chat Protection remains available regardless. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../duckchat/impl/history/ChatHistoryFragment.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 cf5fa39b095e..23fe37b0ccd9 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 @@ -404,7 +404,13 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { private fun showOverflowPopup(anchor: View) { val popup = PopupMenu(layoutInflater, R.layout.popup_chat_history_overflow) val view = popup.contentView - popup.onMenuItemClicked(view.findViewById(R.id.selectChats)) { viewModel.onEnterSelectMode() } + val selectChats = view.findViewById(R.id.selectChats) + if (viewModel.uiState.value is ChatHistoryUiState.Loaded) { + selectChats.show() + popup.onMenuItemClicked(selectChats) { viewModel.onEnterSelectMode() } + } else { + selectChats.gone() + } popup.onMenuItemClicked(view.findViewById(R.id.chatsProtection)) { viewModel.onChatsProtectionClicked() } popup.show(binding.root, anchor) }