Skip to content

Commit b3157b9

Browse files
committed
Wire per-row Pin/Unpin in chat history overflow menu
1 parent 2ebe300 commit b3157b9

6 files changed

Lines changed: 114 additions & 1 deletion

File tree

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

Lines changed: 5 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
@@ -286,7 +287,10 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) {
286287
private fun showRowPopup(item: ChatHistoryItem, anchor: View) {
287288
val popup = PopupMenu(layoutInflater, R.layout.popup_chat_history_row)
288289
val view = popup.contentView
289-
popup.onMenuItemClicked(view.findViewById(R.id.pin)) { showComingSoonSnackbar() }
290+
val pinAction = view.findViewById<PopupMenuItemView>(R.id.pin)
291+
val pinLabel = if (item.pinned) R.string.duck_ai_chat_history_action_unpin else R.string.duck_ai_chat_history_action_pin
292+
pinAction.setPrimaryText(getString(pinLabel))
293+
popup.onMenuItemClicked(pinAction) { viewModel.onTogglePin(item.chatId) }
290294
popup.onMenuItemClicked(view.findViewById(R.id.rename)) {
291295
viewModel.onRenameRequested(item.chatId, item.displayTitle)
292296
}

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

Lines changed: 5 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)
39+
suspend fun setPinned(chatId: String, pinned: Boolean)
3940
}
4041

4142
@ContributesBinding(AppScope::class)
@@ -64,6 +65,10 @@ class RealChatHistoryRepository @Inject constructor(
6465
withContext(dispatchers.io()) { chatStore.renameChat(chatId, newTitle) }
6566
}
6667

68+
override suspend fun setPinned(chatId: String, pinned: Boolean) {
69+
withContext(dispatchers.io()) { chatStore.setPinned(chatId, pinned) }
70+
}
71+
6772
private fun toChatHistoryItem(chat: DuckAiChat): ChatHistoryItem = ChatHistoryItem(
6873
chatId = chat.chatId,
6974
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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,11 @@ class ChatHistoryViewModel @Inject constructor(
150150
navigationChannel.trySend(NavigationEvent.OpenRename(chatId = chatId, currentTitle = currentTitle))
151151
}
152152

153+
fun onTogglePin(chatId: String) {
154+
val current = latestItems.firstOrNull { it.chatId == chatId } ?: return
155+
appScope.launch { chatHistoryRepository.setPinned(chatId, !current.pinned) }
156+
}
157+
153158
private fun dispatchSelectedClear(chatIds: Set<String>) {
154159
if (chatIds.isEmpty()) return
155160
val urls = chatIds.mapTo(mutableSetOf()) { duckChat.buildChatUrl(it) }

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,46 @@ class ChatHistoryViewModelTest {
710710
}
711711
}
712712

713+
@Test
714+
fun `onTogglePin flips pinned to true when row was unpinned`() = coroutineRule.testScope.runTest {
715+
source.value = listOf(item("a", pinned = false))
716+
717+
viewModel.uiState.test {
718+
awaitInitialLoaded()
719+
viewModel.onTogglePin("a")
720+
val updated = awaitItem() as Loaded
721+
assertEquals(listOf("a"), updated.pinned.map { it.chatId })
722+
assertEquals(emptyList<String>(), updated.recent.map { it.chatId })
723+
}
724+
assertEquals(listOf("a" to true), repository.pinnedChats)
725+
}
726+
727+
@Test
728+
fun `onTogglePin flips pinned to false when row was pinned`() = coroutineRule.testScope.runTest {
729+
source.value = listOf(item("a", pinned = true))
730+
731+
viewModel.uiState.test {
732+
awaitInitialLoaded()
733+
viewModel.onTogglePin("a")
734+
val updated = awaitItem() as Loaded
735+
assertEquals(emptyList<String>(), updated.pinned.map { it.chatId })
736+
assertEquals(listOf("a"), updated.recent.map { it.chatId })
737+
}
738+
assertEquals(listOf("a" to false), repository.pinnedChats)
739+
}
740+
741+
@Test
742+
fun `onTogglePin is a no-op when chatId is unknown`() = coroutineRule.testScope.runTest {
743+
source.value = listOf(item("a", pinned = false))
744+
745+
viewModel.uiState.test {
746+
awaitInitialLoaded()
747+
viewModel.onTogglePin("missing")
748+
expectNoEvents()
749+
}
750+
assertTrue(repository.pinnedChats.isEmpty())
751+
}
752+
713753
@Test
714754
fun `concurrent repository emission removing a selected id reconciles the selection`() = runTest {
715755
source.value = listOf(item("a"), item("b"), item("c"))
@@ -762,6 +802,7 @@ private class FakeChatHistoryRepository(
762802
) : ChatHistoryRepository {
763803
val deletedChatIds: MutableList<String> = mutableListOf()
764804
val renamedChats: MutableList<Pair<String, String>> = mutableListOf()
805+
val pinnedChats: MutableList<Pair<String, Boolean>> = mutableListOf()
765806
var deleteAllChatsCalled: Boolean = false
766807
private set
767808

@@ -780,6 +821,11 @@ private class FakeChatHistoryRepository(
780821
override suspend fun renameChat(chatId: String, newTitle: String) {
781822
renamedChats += chatId to newTitle
782823
}
824+
825+
override suspend fun setPinned(chatId: String, pinned: Boolean) {
826+
pinnedChats += chatId to pinned
827+
source.value = source.value.map { if (it.chatId == chatId) it.copy(pinned = pinned) else it }
828+
}
783829
}
784830

785831
private class RecordingDataClearingTrigger : DataClearingTrigger {

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ 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+
/** Sets the pinned flag on [chatId]. Returns true if the chat existed and was updated, false otherwise. */
71+
suspend fun setPinned(chatId: String, pinned: Boolean): Boolean
6972
}
7073

7174
@SingleInstanceIn(AppScope::class)
@@ -143,6 +146,17 @@ class RealDuckAiChatStore @Inject constructor(
143146
true
144147
}
145148

149+
override suspend fun setPinned(chatId: String, pinned: Boolean): Boolean =
150+
withContext(dispatchers.io()) {
151+
logcat { "DuckAI: RealDuckAiChatStore.setPinned($chatId, $pinned)" }
152+
val entity = chatsDao.getById(chatId) ?: return@withContext false
153+
val updatedJson = runCatching {
154+
JSONObject(entity.data).put("pinned", pinned).toString()
155+
}.getOrNull() ?: return@withContext false
156+
chatsDao.upsert(entity.copy(data = updatedJson))
157+
true
158+
}
159+
146160
private fun DuckAiBridgeChatEntity.toDuckAiChat(): DuckAiChat? = runCatching {
147161
val json = JSONObject(data)
148162
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: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,4 +336,43 @@ class RealDuckAiChatStoreTest {
336336
// traversal file should not be deleted — it's outside filesDir
337337
verify(chatsDao).deleteAll()
338338
}
339+
340+
// --- setPinned ---
341+
342+
@Test
343+
fun `setPinned returns false when chat not found`() = runTest {
344+
whenever(chatsDao.getById("missing")).thenReturn(null)
345+
346+
assertFalse(store.setPinned("missing", true))
347+
verify(chatsDao, never()).upsert(any())
348+
}
349+
350+
@Test
351+
fun `setPinned updates only the pinned flag and preserves other JSON fields`() = runTest {
352+
val originalJson = """
353+
{"chatId":"abc","title":"Old","model":"gpt-5-mini","lastEdit":"2026-04-01T21:31:54.260Z","pinned":false,"fileRefs":["uuid1"],"messages":[{"role":"user","text":"hi"}]}
354+
""".trimIndent()
355+
whenever(chatsDao.getById("abc")).thenReturn(DuckAiBridgeChatEntity("abc", originalJson))
356+
357+
assertTrue(store.setPinned("abc", true))
358+
359+
val entityCaptor = argumentCaptor<DuckAiBridgeChatEntity>()
360+
verify(chatsDao).upsert(entityCaptor.capture())
361+
val json = JSONObject(entityCaptor.firstValue.data)
362+
assertTrue(json.getBoolean("pinned"))
363+
assertEquals("Old", json.getString("title"))
364+
assertEquals("abc", json.getString("chatId"))
365+
assertEquals("gpt-5-mini", json.getString("model"))
366+
assertEquals("2026-04-01T21:31:54.260Z", json.getString("lastEdit"))
367+
assertEquals("uuid1", json.getJSONArray("fileRefs").getString(0))
368+
assertEquals("hi", json.getJSONArray("messages").getJSONObject(0).getString("text"))
369+
}
370+
371+
@Test
372+
fun `setPinned returns false when stored JSON is malformed`() = runTest {
373+
whenever(chatsDao.getById("abc")).thenReturn(DuckAiBridgeChatEntity("abc", "not a json"))
374+
375+
assertFalse(store.setPinned("abc", true))
376+
verify(chatsDao, never()).upsert(any())
377+
}
339378
}

0 commit comments

Comments
 (0)