Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,24 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) {
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
.onEach(::render)
.launchIn(viewLifecycleOwner.lifecycleScope)

viewModel.navigationEvents
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
.onEach(::onNavigationEvent)
.launchIn(viewLifecycleOwner.lifecycleScope)
}

private fun onNavigationEvent(event: ChatHistoryViewModel.NavigationEvent) {
when (event) {
is ChatHistoryViewModel.NavigationEvent.OpenRename -> openRenameScreen(event.chatId, event.currentTitle)
}
}

private fun openRenameScreen(chatId: String, currentTitle: String) {
parentFragmentManager.beginTransaction()
.replace(R.id.chatHistoryFragmentContainer, RenameChatFragment.newInstance(chatId, currentTitle))
.addToBackStack(null)
.commit()
}

private fun render(state: ChatHistoryUiState) {
Expand Down Expand Up @@ -269,7 +287,9 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) {
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.rename)) {
viewModel.onRenameRequested(item.chatId, item.displayTitle)
}
popup.onMenuItemClicked(view.findViewById(R.id.download)) { showComingSoonSnackbar() }
popup.onMenuItemClicked(view.findViewById(R.id.delete)) { viewModel.onDeleteSingleChat(item.chatId) }
popup.show(binding.root, anchor)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ interface ChatHistoryRepository {

suspend fun deleteChat(chatId: String)
suspend fun deleteAllChats()
suspend fun renameChat(chatId: String, newTitle: String): Boolean
}

@ContributesBinding(AppScope::class)
Expand All @@ -59,6 +60,9 @@ class RealChatHistoryRepository @Inject constructor(
withContext(dispatchers.io()) { chatStore.deleteAllChats() }
}

override suspend fun renameChat(chatId: String, newTitle: String): Boolean =
withContext(dispatchers.io()) { chatStore.renameChat(chatId, newTitle) }

private fun toChatHistoryItem(chat: DuckAiChat): ChatHistoryItem = ChatHistoryItem(
chatId = chat.chatId,
displayTitle = chat.title.takeIf { it.isNotBlank() && it != UPSTREAM_UNTITLED } ?: fallbackTitle,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,15 @@ 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.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
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.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
Expand All @@ -50,6 +54,9 @@ class ChatHistoryViewModel @Inject constructor(

private val controls = MutableStateFlow(UiControls())

private val navigationChannel = Channel<NavigationEvent>(capacity = Channel.BUFFERED, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val navigationEvents: Flow<NavigationEvent> = navigationChannel.receiveAsFlow()

/** Cached snapshot so non-suspend action methods can read Recent without re-subscribing. */
private var latestItems: List<ChatHistoryItem> = emptyList()

Expand Down Expand Up @@ -123,6 +130,10 @@ class ChatHistoryViewModel @Inject constructor(
dispatchSelectedClear(setOf(chatId))
}

fun onRenameRequested(chatId: String, currentTitle: String) {
navigationChannel.trySend(NavigationEvent.OpenRename(chatId = chatId, currentTitle = currentTitle))
}

private fun dispatchSelectedClear(chatIds: Set<String>) {
if (chatIds.isEmpty()) return
if (!duckAiFeatureState.showClearDuckAIChatHistory.value) return
Expand Down Expand Up @@ -247,6 +258,10 @@ class ChatHistoryViewModel @Inject constructor(
val query: String = "",
)

sealed interface NavigationEvent {
data class OpenRename(val chatId: String, val currentTitle: String) : NavigationEvent
}

private companion object {
const val STOP_TIMEOUT_MILLIS = 5_000L
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* 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.os.Bundle
import android.text.Editable
import android.view.View
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.common.ui.DuckDuckGoFragment
import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.common.utils.FragmentViewModelFactory
import com.duckduckgo.common.utils.extensions.hideKeyboard
import com.duckduckgo.common.utils.text.TextChangedWatcher
import com.duckduckgo.di.scopes.FragmentScope
import com.duckduckgo.duckchat.impl.R
import com.duckduckgo.duckchat.impl.databinding.FragmentRenameChatBinding
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject

@InjectWith(FragmentScope::class)
class RenameChatFragment : DuckDuckGoFragment(R.layout.fragment_rename_chat) {

@Inject
lateinit var viewModelFactory: FragmentViewModelFactory

private val binding: FragmentRenameChatBinding by viewBinding()
private val viewModel: RenameChatViewModel by lazy {
ViewModelProvider(this, viewModelFactory)[RenameChatViewModel::class.java]
}

private val chatId: String by lazy { checkNotNull(requireArguments().getString(ARG_CHAT_ID)) }
private val initialTitle: String by lazy { requireArguments().getString(ARG_CURRENT_TITLE).orEmpty() }

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

binding.toolbar.setTitle(R.string.duck_ai_chat_history_rename_title)
binding.toolbar.setNavigationIcon(com.duckduckgo.mobile.android.R.drawable.ic_arrow_left_24)
binding.toolbar.setNavigationOnClickListener { dismiss() }
binding.toolbar.inflateMenu(R.menu.menu_rename_chat)
binding.toolbar.setOnMenuItemClickListener { item ->
if (item.itemId == R.id.action_rename_confirm) {
onConfirmClicked()
true
} else {
false
}
}

binding.titleInput.text = initialTitle
binding.titleInput.addTextChangedListener(titleTextWatcher)
binding.titleInput.showKeyboardDelayed()
setConfirmEnabled(false)

viewModel.results
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
.onEach(::onRenameResult)
.launchIn(viewLifecycleOwner.lifecycleScope)
}

private fun onConfirmClicked() {
viewModel.onSaveClicked(chatId, binding.titleInput.text)
}

private fun onRenameResult(result: RenameChatViewModel.RenameResult) {
when (result) {
RenameChatViewModel.RenameResult.Success -> dismiss()
RenameChatViewModel.RenameResult.Error ->
Snackbar.make(binding.root, R.string.duck_ai_chat_history_rename_error, Snackbar.LENGTH_SHORT).show()
}
}

private fun dismiss() {
requireActivity().hideKeyboard()
parentFragmentManager.popBackStack()
}

private fun setConfirmEnabled(enabled: Boolean) {
binding.toolbar.menu.findItem(R.id.action_rename_confirm)?.isEnabled = enabled
}

private val titleTextWatcher = object : TextChangedWatcher() {
override fun afterTextChanged(editable: Editable) {
val candidate = editable.toString().trim()
setConfirmEnabled(candidate.isNotBlank() && candidate != initialTitle.trim())
}
}

companion object {
private const val ARG_CHAT_ID = "arg_chat_id"
private const val ARG_CURRENT_TITLE = "arg_current_title"

fun newInstance(chatId: String, currentTitle: String): RenameChatFragment = RenameChatFragment().apply {
arguments = Bundle().apply {
putString(ARG_CHAT_ID, chatId)
putString(ARG_CURRENT_TITLE, currentTitle)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* 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 androidx.lifecycle.ViewModel
import com.duckduckgo.anvil.annotations.ContributesViewModel
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.di.scopes.FragmentScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import javax.inject.Inject

@ContributesViewModel(FragmentScope::class)
class RenameChatViewModel @Inject constructor(
private val chatHistoryRepository: ChatHistoryRepository,
@AppCoroutineScope private val appScope: CoroutineScope,
) : ViewModel() {

private val resultChannel = Channel<RenameResult>(capacity = Channel.BUFFERED, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val results: Flow<RenameResult> = resultChannel.receiveAsFlow()

fun onSaveClicked(chatId: String, newTitle: String) {
appScope.launch {
val outcome = try {
if (chatHistoryRepository.renameChat(chatId, newTitle.trim())) RenameResult.Success else RenameResult.Error
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
RenameResult.Error
}
resultChannel.trySend(outcome)
}
}

sealed interface RenameResult {
data object Success : RenameResult
data object Error : RenameResult
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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.
-->

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.duckduckgo.duckchat.impl.history.RenameChatFragment">

<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/Widget.DuckDuckGo.ToolbarTheme"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">

<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/daxColorToolbar"
android:theme="@style/Widget.DuckDuckGo.ToolbarTheme"
app:popupTheme="@style/Widget.DuckDuckGo.PopUpOverflowMenu" />
</com.google.android.material.appbar.AppBarLayout>

<com.duckduckgo.common.ui.view.text.DaxTextInput
android:id="@+id/titleInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/keyline_4"
android:hint="@string/duck_ai_chat_history_rename_chat_title_hint"
app:capitalizeKeyboard="true"
Comment thread
GerardPaligot marked this conversation as resolved.
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appBarLayout"
app:type="single_line" />

</androidx.constraintlayout.widget.ConstraintLayout>
26 changes: 26 additions & 0 deletions duckchat/duckchat-impl/src/main/res/menu/menu_rename_chat.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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.
-->

<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_rename_confirm"
android:contentDescription="@string/duck_ai_chat_history_rename_confirm_content_description"
android:enabled="false"
android:icon="@drawable/ic_check_24"
android:title="@string/duck_ai_chat_history_rename_confirm_title"
app:showAsAction="always" />
</menu>
5 changes: 5 additions & 0 deletions duckchat/duckchat-impl/src/main/res/values/donottranslate.xml
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,9 @@
<string name="duck_ai_chat_history_row_selected_content_description">Selected</string>
<string name="duck_ai_chat_history_row_unselected_content_description">Not selected</string>
<string name="duck_ai_chat_history_exit_select_mode_content_description">Exit selection mode</string>
<string name="duck_ai_chat_history_rename_title">Rename Chat</string>
<string name="duck_ai_chat_history_rename_chat_title_hint">Chat Title</string>
<string name="duck_ai_chat_history_rename_confirm_title">Save</string>
<string name="duck_ai_chat_history_rename_confirm_content_description">Save chat title</string>
<string name="duck_ai_chat_history_rename_error">Couldn\'t rename chat</string>
</resources>
Loading
Loading