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..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 @@ -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) + nativeChatInputParameters(), 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 ef06b06cbeff..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 @@ -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 @@ -182,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) @@ -287,6 +304,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 +316,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 +379,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 +401,20 @@ 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 + 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) + } + 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/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 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..2246c8474f1f 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 + Chat Protection 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) {