Skip to content

Commit 90d8ca9

Browse files
Wire per-row Pin/Unpin in chat history overflow menu (#8604)
Task/Issue URL: https://app.asana.com/1/137249556945/project/72649045549333/task/1214820120386824?focus=true ### Description Wires the **Pin / Unpin** action on the Duck.ai chat history screen so a chat actually moves between the Pinned and Recent sections. The toggle fires immediately with no confirmation; a snackbar with **Undo** appears so a mistap is recoverable. State is persisted via the native chat store so the same Pinned/Recent split shows on next open and on Duck.ai itself. ### Steps to test this PR > [!NOTE] > Prerequisites: > - [ ] Install Internal Debug. > - [ ] In Settings → Developer Settings → Feature Flags, confirm `duckAiChatHistory` (`self`, `historyScreen`) is ON and `duckChat → useNativeStorageChatData` is ON. > - [ ] Create at least 2 chats in Duck.ai so there's something to pin. _Happy path_ - [ ] Open the Chats screen → tap the 3-dot on a Recent row → tap **Pin** → confirm the row moves to a Pinned section above Recent and a "Chat pinned" snackbar appears with an **Undo** action. - [ ] Tap the 3-dot on a Pinned row → tap **Unpin** → confirm the row moves back to Recent and a "Chat unpinned" snackbar appears with **Undo**. - [ ] Trigger a pin → tap **Undo** before the snackbar auto-dismisses → confirm the row returns to its original section. - [ ] Trigger an unpin → tap **Undo** → confirm the row returns to the Pinned section. _Persistence_ - [ ] Pin a chat, close and reopen the Chats screen → confirm the chat is still in the Pinned section. - [ ] Pin a chat on Android, then open the same chat on Duck.ai web → confirm it shows as pinned there too (and vice versa). ### UI changes | Before | After | | ------ | ----- | <img width="270" height="600" alt="image" src="https://github.com/user-attachments/assets/4da61325-333c-4e45-af7b-6d728a24f448" /> | <img width="270" height="600" alt="image" src="https://github.com/user-attachments/assets/f6f87b66-262b-40a0-aa34-3009f77c8aa8" /> <img width="270" height="600" alt="image" src="https://github.com/user-attachments/assets/0542f8ba-1ded-4d16-b680-47fba6be5202" /> | <img width="270" height="600" alt="image" src="https://github.com/user-attachments/assets/6078d394-7591-4798-b9f3-7c4830cb3621" /> N/A | <img width="270" height="600" alt="image" src="https://github.com/user-attachments/assets/aba16786-9067-4d2f-af0d-1297924bbf25" />
1 parent 0f9c816 commit 90d8ca9

8 files changed

Lines changed: 242 additions & 1 deletion

File tree

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryFragment.kt

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
2929
import com.duckduckgo.anvil.annotations.InjectWith
3030
import com.duckduckgo.common.ui.DuckDuckGoFragment
3131
import com.duckduckgo.common.ui.menu.PopupMenu
32+
import com.duckduckgo.common.ui.view.PopupMenuItemView
3233
import com.duckduckgo.common.ui.view.SearchBar
3334
import com.duckduckgo.common.ui.view.gone
3435
import com.duckduckgo.common.ui.view.show
@@ -124,6 +125,11 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) {
124125
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
125126
.onEach(::onNavigationEvent)
126127
.launchIn(viewLifecycleOwner.lifecycleScope)
128+
129+
viewModel.messageEvents
130+
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
131+
.onEach(::onMessageEvent)
132+
.launchIn(viewLifecycleOwner.lifecycleScope)
127133
}
128134

129135
private fun onNavigationEvent(event: ChatHistoryViewModel.NavigationEvent) {
@@ -132,13 +138,32 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) {
132138
}
133139
}
134140

141+
private fun onMessageEvent(event: ChatHistoryViewModel.MessageEvent) {
142+
when (event) {
143+
is ChatHistoryViewModel.MessageEvent.PinToggled -> showPinToggledSnackbar(event)
144+
}
145+
}
146+
135147
private fun openRenameScreen(chatId: String, currentTitle: String) {
136148
parentFragmentManager.beginTransaction()
137149
.replace(R.id.chatHistoryFragmentContainer, RenameChatFragment.newInstance(chatId, currentTitle))
138150
.addToBackStack(null)
139151
.commit()
140152
}
141153

154+
private fun showPinToggledSnackbar(event: ChatHistoryViewModel.MessageEvent.PinToggled) {
155+
val messageRes = if (event.wasPinned) {
156+
R.string.duck_ai_chat_history_unpin_snackbar
157+
} else {
158+
R.string.duck_ai_chat_history_pin_snackbar
159+
}
160+
Snackbar.make(binding.root, messageRes, Snackbar.LENGTH_LONG)
161+
.setAction(R.string.duck_ai_chat_history_undo) {
162+
viewModel.onUndoTogglePin(event.chatId, restorePinned = event.wasPinned)
163+
}
164+
.show()
165+
}
166+
142167
private fun render(state: ChatHistoryUiState) {
143168
logcat { "ChatHistory: render ${state::class.simpleName}" }
144169
when (state) {
@@ -286,7 +311,10 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) {
286311
private fun showRowPopup(item: ChatHistoryItem, anchor: View) {
287312
val popup = PopupMenu(layoutInflater, R.layout.popup_chat_history_row)
288313
val view = popup.contentView
289-
popup.onMenuItemClicked(view.findViewById(R.id.pin)) { showComingSoonSnackbar() }
314+
val pinAction = view.findViewById<PopupMenuItemView>(R.id.pin)
315+
val pinLabel = if (item.pinned) R.string.duck_ai_chat_history_action_unpin else R.string.duck_ai_chat_history_action_pin
316+
pinAction.setPrimaryText(getString(pinLabel))
317+
popup.onMenuItemClicked(pinAction) { viewModel.onTogglePin(item.chatId) }
290318
popup.onMenuItemClicked(view.findViewById(R.id.rename)) {
291319
viewModel.onRenameRequested(item.chatId, item.displayTitle)
292320
}

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryRepository.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ interface ChatHistoryRepository {
3636
suspend fun deleteChat(chatId: String)
3737
suspend fun deleteAllChats()
3838
suspend fun renameChat(chatId: String, newTitle: String): Boolean
39+
suspend fun setPinned(chatId: String, pinned: Boolean)
3940
}
4041

4142
@ContributesBinding(AppScope::class)
@@ -63,6 +64,12 @@ class RealChatHistoryRepository @Inject constructor(
6364
override suspend fun renameChat(chatId: String, newTitle: String): Boolean =
6465
withContext(dispatchers.io()) { chatStore.renameChat(chatId, newTitle) }
6566

67+
override suspend fun setPinned(chatId: String, pinned: Boolean) {
68+
withContext(dispatchers.io()) {
69+
if (pinned) chatStore.pinChat(chatId) else chatStore.unpinChat(chatId)
70+
}
71+
}
72+
6673
private fun toChatHistoryItem(chat: DuckAiChat): ChatHistoryItem = ChatHistoryItem(
6774
chatId = chat.chatId,
6875
displayTitle = chat.title.takeIf { it.isNotBlank() && it != UPSTREAM_UNTITLED } ?: fallbackTitle,

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModel.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ class ChatHistoryViewModel @Inject constructor(
5757
private val navigationChannel = Channel<NavigationEvent>(capacity = Channel.BUFFERED, onBufferOverflow = BufferOverflow.DROP_OLDEST)
5858
val navigationEvents: Flow<NavigationEvent> = navigationChannel.receiveAsFlow()
5959

60+
private val messageChannel = Channel<MessageEvent>(capacity = Channel.BUFFERED, onBufferOverflow = BufferOverflow.DROP_OLDEST)
61+
val messageEvents: Flow<MessageEvent> = messageChannel.receiveAsFlow()
62+
6063
/** Cached snapshot so non-suspend action methods can read Recent without re-subscribing. */
6164
private var latestItems: List<ChatHistoryItem> = emptyList()
6265

@@ -134,6 +137,17 @@ class ChatHistoryViewModel @Inject constructor(
134137
navigationChannel.trySend(NavigationEvent.OpenRename(chatId = chatId, currentTitle = currentTitle))
135138
}
136139

140+
fun onTogglePin(chatId: String) {
141+
val current = latestItems.firstOrNull { it.chatId == chatId } ?: return
142+
val wasPinned = current.pinned
143+
appScope.launch { chatHistoryRepository.setPinned(chatId, !wasPinned) }
144+
messageChannel.trySend(MessageEvent.PinToggled(chatId = chatId, wasPinned = wasPinned))
145+
}
146+
147+
fun onUndoTogglePin(chatId: String, restorePinned: Boolean) {
148+
appScope.launch { chatHistoryRepository.setPinned(chatId, restorePinned) }
149+
}
150+
137151
private fun dispatchSelectedClear(chatIds: Set<String>) {
138152
if (chatIds.isEmpty()) return
139153
if (!duckAiFeatureState.showClearDuckAIChatHistory.value) return
@@ -262,6 +276,10 @@ class ChatHistoryViewModel @Inject constructor(
262276
data class OpenRename(val chatId: String, val currentTitle: String) : NavigationEvent
263277
}
264278

279+
sealed interface MessageEvent {
280+
data class PinToggled(val chatId: String, val wasPinned: Boolean) : MessageEvent
281+
}
282+
265283
private companion object {
266284
const val STOP_TIMEOUT_MILLIS = 5_000L
267285
}

duckchat/duckchat-impl/src/main/res/values/donottranslate.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,7 @@
107107
<string name="duck_ai_chat_history_rename_confirm_title">Save</string>
108108
<string name="duck_ai_chat_history_rename_confirm_content_description">Save chat title</string>
109109
<string name="duck_ai_chat_history_rename_error">Couldn\'t rename chat</string>
110+
<string name="duck_ai_chat_history_pin_snackbar">Chat pinned</string>
111+
<string name="duck_ai_chat_history_unpin_snackbar">Chat unpinned</string>
112+
<string name="duck_ai_chat_history_undo">Undo</string>
110113
</resources>

duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModelTest.kt

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,106 @@ class ChatHistoryViewModelTest {
818818
}
819819
}
820820

821+
@Test
822+
fun `onTogglePin flips pinned to true when row was unpinned`() = coroutineRule.testScope.runTest {
823+
source.value = listOf(item("a", pinned = false))
824+
825+
viewModel.uiState.test {
826+
awaitInitialLoaded()
827+
viewModel.onTogglePin("a")
828+
val updated = awaitItem() as Loaded
829+
assertEquals(listOf("a"), updated.pinned.map { it.chatId })
830+
assertEquals(emptyList<String>(), updated.recent.map { it.chatId })
831+
}
832+
assertEquals(listOf("a" to true), repository.pinnedChats)
833+
}
834+
835+
@Test
836+
fun `onTogglePin flips pinned to false when row was pinned`() = coroutineRule.testScope.runTest {
837+
source.value = listOf(item("a", pinned = true))
838+
839+
viewModel.uiState.test {
840+
awaitInitialLoaded()
841+
viewModel.onTogglePin("a")
842+
val updated = awaitItem() as Loaded
843+
assertEquals(emptyList<String>(), updated.pinned.map { it.chatId })
844+
assertEquals(listOf("a"), updated.recent.map { it.chatId })
845+
}
846+
assertEquals(listOf("a" to false), repository.pinnedChats)
847+
}
848+
849+
@Test
850+
fun `onTogglePin is a no-op when chatId is unknown`() = coroutineRule.testScope.runTest {
851+
source.value = listOf(item("a", pinned = false))
852+
853+
viewModel.uiState.test {
854+
awaitInitialLoaded()
855+
viewModel.onTogglePin("missing")
856+
expectNoEvents()
857+
}
858+
assertTrue(repository.pinnedChats.isEmpty())
859+
}
860+
861+
@Test
862+
fun `onTogglePin emits PinToggled message event with previous pinned state false`() = coroutineRule.testScope.runTest {
863+
source.value = listOf(item("a", pinned = false))
864+
865+
viewModel.messageEvents.test {
866+
// Drain the initial Loaded state so latestItems is primed.
867+
viewModel.uiState.test { awaitInitialLoaded() }
868+
viewModel.onTogglePin("a")
869+
870+
val event = awaitItem()
871+
assertTrue(event is ChatHistoryViewModel.MessageEvent.PinToggled)
872+
event as ChatHistoryViewModel.MessageEvent.PinToggled
873+
assertEquals("a", event.chatId)
874+
assertEquals(false, event.wasPinned)
875+
}
876+
}
877+
878+
@Test
879+
fun `onTogglePin emits PinToggled message event with previous pinned state true`() = coroutineRule.testScope.runTest {
880+
source.value = listOf(item("a", pinned = true))
881+
882+
viewModel.messageEvents.test {
883+
viewModel.uiState.test { awaitInitialLoaded() }
884+
viewModel.onTogglePin("a")
885+
886+
val event = awaitItem()
887+
assertTrue(event is ChatHistoryViewModel.MessageEvent.PinToggled)
888+
event as ChatHistoryViewModel.MessageEvent.PinToggled
889+
assertEquals("a", event.chatId)
890+
assertEquals(true, event.wasPinned)
891+
}
892+
}
893+
894+
@Test
895+
fun `onTogglePin emits no message event when chatId is unknown`() = coroutineRule.testScope.runTest {
896+
source.value = listOf(item("a", pinned = false))
897+
898+
viewModel.messageEvents.test {
899+
viewModel.uiState.test { awaitInitialLoaded() }
900+
viewModel.onTogglePin("missing")
901+
expectNoEvents()
902+
}
903+
}
904+
905+
@Test
906+
fun `onUndoTogglePin restores the requested pinned state via the repository`() = coroutineRule.testScope.runTest {
907+
source.value = listOf(item("a", pinned = true))
908+
909+
viewModel.uiState.test {
910+
awaitInitialLoaded()
911+
viewModel.onTogglePin("a") // a → unpinned
912+
awaitItem() // Loaded after the unpin write
913+
viewModel.onUndoTogglePin("a", restorePinned = true)
914+
val restored = awaitItem() as Loaded
915+
assertEquals(listOf("a"), restored.pinned.map { it.chatId })
916+
assertEquals(emptyList<String>(), restored.recent.map { it.chatId })
917+
}
918+
assertEquals(listOf("a" to false, "a" to true), repository.pinnedChats)
919+
}
920+
821921
/**
822922
* `stateIn(WhileSubscribed)` does not guarantee subscribers observe the `Loading` initial
823923
* value — the upstream may emit before the StateFlow can replay it. Tolerate both orderings.
@@ -851,6 +951,7 @@ private class FakeChatHistoryRepository(
851951
) : ChatHistoryRepository {
852952
val deletedChatIds: MutableList<String> = mutableListOf()
853953
val renamedChats: MutableList<Pair<String, String>> = mutableListOf()
954+
val pinnedChats: MutableList<Pair<String, Boolean>> = mutableListOf()
854955
var deleteAllChatsCalled: Boolean = false
855956
private set
856957

@@ -870,6 +971,11 @@ private class FakeChatHistoryRepository(
870971
renamedChats += chatId to newTitle
871972
return true
872973
}
974+
975+
override suspend fun setPinned(chatId: String, pinned: Boolean) {
976+
pinnedChats += chatId to pinned
977+
source.value = source.value.map { if (it.chatId == chatId) it.copy(pinned = pinned) else it }
978+
}
873979
}
874980

875981
private class RecordingDataClearingTrigger : DataClearingTrigger {

duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/history/RenameChatViewModelTest.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,6 @@ private class RecordingRenameRepository : ChatHistoryRepository {
8181
renames += chatId to newTitle
8282
return nextResult
8383
}
84+
85+
override suspend fun setPinned(chatId: String, pinned: Boolean) = Unit
8486
}

duckchat/duckchat-store/src/main/java/com/duckduckgo/duckchat/store/impl/RealDuckAiChatStore.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ interface DuckAiChatStore {
6666

6767
/** Renames [chatId] in the FE-owned JSON blob. Returns true if the chat existed and was updated, false otherwise. */
6868
suspend fun renameChat(chatId: String, newTitle: String): Boolean
69+
70+
/** Pins [chatId]. Silent no-op if the chat is not found or the stored JSON is malformed. */
71+
suspend fun pinChat(chatId: String)
72+
73+
/** Unpins [chatId]. Silent no-op if the chat is not found or the stored JSON is malformed. */
74+
suspend fun unpinChat(chatId: String)
6975
}
7076

7177
@SingleInstanceIn(AppScope::class)
@@ -143,6 +149,21 @@ class RealDuckAiChatStore @Inject constructor(
143149
true
144150
}
145151

152+
override suspend fun pinChat(chatId: String) = setPinned(chatId, pinned = true)
153+
154+
override suspend fun unpinChat(chatId: String) = setPinned(chatId, pinned = false)
155+
156+
private suspend fun setPinned(chatId: String, pinned: Boolean) {
157+
withContext(dispatchers.io()) {
158+
logcat { "DuckAI: RealDuckAiChatStore.setPinned($chatId, $pinned)" }
159+
val entity = chatsDao.getById(chatId) ?: return@withContext
160+
val updatedJson = runCatching {
161+
JSONObject(entity.data).put("pinned", pinned).toString()
162+
}.getOrNull() ?: return@withContext
163+
chatsDao.upsert(entity.copy(data = updatedJson))
164+
}
165+
}
166+
146167
private fun DuckAiBridgeChatEntity.toDuckAiChat(): DuckAiChat? = runCatching {
147168
val json = JSONObject(data)
148169
val chatId = json.optString("chatId").takeIf { it.isNotEmpty() } ?: return@runCatching null

duckchat/duckchat-store/src/test/kotlin/com/duckduckgo/duckchat/store/impl/RealDuckAiChatStoreTest.kt

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,4 +336,60 @@ class RealDuckAiChatStoreTest {
336336
// traversal file should not be deleted — it's outside filesDir
337337
verify(chatsDao).deleteAll()
338338
}
339+
340+
// --- pinChat / unpinChat ---
341+
342+
@Test
343+
fun `pinChat is a no-op when chat not found`() = runTest {
344+
whenever(chatsDao.getById("missing")).thenReturn(null)
345+
346+
store.pinChat("missing")
347+
348+
verify(chatsDao, never()).upsert(any())
349+
}
350+
351+
@Test
352+
fun `pinChat sets pinned to true and preserves other JSON fields`() = runTest {
353+
val originalJson = """
354+
{"chatId":"abc","title":"Old","model":"gpt-5-mini","lastEdit":"2026-04-01T21:31:54.260Z","pinned":false,"fileRefs":["uuid1"],"messages":[{"role":"user","text":"hi"}]}
355+
""".trimIndent()
356+
whenever(chatsDao.getById("abc")).thenReturn(DuckAiBridgeChatEntity("abc", originalJson))
357+
358+
store.pinChat("abc")
359+
360+
val entityCaptor = argumentCaptor<DuckAiBridgeChatEntity>()
361+
verify(chatsDao).upsert(entityCaptor.capture())
362+
val json = JSONObject(entityCaptor.firstValue.data)
363+
assertTrue(json.getBoolean("pinned"))
364+
assertEquals("Old", json.getString("title"))
365+
assertEquals("abc", json.getString("chatId"))
366+
assertEquals("gpt-5-mini", json.getString("model"))
367+
assertEquals("2026-04-01T21:31:54.260Z", json.getString("lastEdit"))
368+
assertEquals("uuid1", json.getJSONArray("fileRefs").getString(0))
369+
assertEquals("hi", json.getJSONArray("messages").getJSONObject(0).getString("text"))
370+
}
371+
372+
@Test
373+
fun `unpinChat sets pinned to false`() = runTest {
374+
val originalJson = """
375+
{"chatId":"abc","title":"Old","model":"gpt-5-mini","lastEdit":"2026-04-01T21:31:54.260Z","pinned":true}
376+
""".trimIndent()
377+
whenever(chatsDao.getById("abc")).thenReturn(DuckAiBridgeChatEntity("abc", originalJson))
378+
379+
store.unpinChat("abc")
380+
381+
val entityCaptor = argumentCaptor<DuckAiBridgeChatEntity>()
382+
verify(chatsDao).upsert(entityCaptor.capture())
383+
val json = JSONObject(entityCaptor.firstValue.data)
384+
assertFalse(json.getBoolean("pinned"))
385+
}
386+
387+
@Test
388+
fun `pinChat is a no-op when stored JSON is malformed`() = runTest {
389+
whenever(chatsDao.getById("abc")).thenReturn(DuckAiBridgeChatEntity("abc", "not a json"))
390+
391+
store.pinChat("abc")
392+
393+
verify(chatsDao, never()).upsert(any())
394+
}
339395
}

0 commit comments

Comments
 (0)