From 700ff601fb36476f5e7691294fd5d4f9194f2cfc Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:35:28 +0000 Subject: [PATCH] fix(messaging): let a pinned conversation be unpinned (#7492) --- .../feature/messaging/ui/contact/Contacts.kt | 6 +- .../contact/ContactsSelectionToolbarTest.kt | 143 ++++++++++++++++++ 2 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsSelectionToolbarTest.kt diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt index 7ac2afa496..f3098c2ed9 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt @@ -184,9 +184,9 @@ fun ContactsScreen( } } - // Derived state for selected contacts and count - val selectedContacts = - remember(contacts, selectedContactKeys) { contacts.filter { it.contactKey in selectedContactKeys } } + // selectedContactKeys is mutated in place, so as a remember key it never changes; read it as state instead. + val selectedContacts by + remember(contacts) { derivedStateOf { contacts.filter { it.contactKey in selectedContactKeys } } } // Get message count directly from repository for selected contacts var selectedCount by remember { mutableIntStateOf(0) } LaunchedEffect(selectedContactKeys.size, selectedContactKeys.joinToString(",")) { diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsSelectionToolbarTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsSelectionToolbarTest.kt new file mode 100644 index 0000000000..bfbee531d3 --- /dev/null +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsSelectionToolbarTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.messaging.ui.contact + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.lifecycle.SavedStateHandle +import dev.mokkery.MockMode +import dev.mokkery.answering.calls +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.everySuspend +import dev.mokkery.matcher.any +import dev.mokkery.mock +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.ContactKey +import org.meshtastic.core.model.ContactSettings +import org.meshtastic.core.repository.ConnectionStateProvider +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.testing.FakeNodeRepository +import org.meshtastic.core.testing.TestDataFactory +import org.meshtastic.core.ui.util.SnackbarManager +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.ChannelSettings +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The toolbar derives pin and mute state from the conversations currently selected, so it has to follow the selection + * as rows are long-pressed rather than the list as it stood when the selection was last empty. + */ +@OptIn(ExperimentalTestApi::class, ExperimentalCoroutinesApi::class) +class ContactsSelectionToolbarTest { + + private val channelKey = ContactKey.broadcast(0).value + private val nodeRepository = FakeNodeRepository() + private val packetRepository: PacketRepository = mock(MockMode.autofill) + private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) + private val connectionStateProvider: ConnectionStateProvider = mock(MockMode.autofill) + private val pinWrites = MutableStateFlow(emptyList()) + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo()) + every { connectionStateProvider.connectionState } returns MutableStateFlow(ConnectionState.Disconnected) + every { packetRepository.getUnreadCountTotal() } returns MutableStateFlow(0) + every { packetRepository.getContacts() } returns MutableStateFlow(emptyMap()) + every { radioConfigRepository.channelSetFlow } returns + MutableStateFlow( + ChannelSet.Builder().settings(listOf(ChannelSettings.Builder().name(CHANNEL_NAME).build())).build(), + ) + everySuspend { packetRepository.setPinned(any(), true) } calls { pinWrites.update { it + true } } + everySuspend { packetRepository.setPinned(any(), false) } calls { pinWrites.update { it + false } } + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun ComposeUiTest.showContacts(settings: ContactSettings) { + every { packetRepository.getContactSettings() } returns MutableStateFlow(mapOf(channelKey to settings)) + val viewModel = + ContactsViewModel( + savedStateHandle = SavedStateHandle(), + nodeRepository = nodeRepository, + packetRepository = packetRepository, + snackbarManager = SnackbarManager(), + radioConfigRepository = radioConfigRepository, + connectionStateProvider = connectionStateProvider, + ) + setContent { + MaterialTheme { + ContactsScreen( + onNavigateToShare = {}, + onHandleDeepLink = { _, _ -> }, + viewModel = viewModel, + onClickNodeChip = {}, + onNavigateToMessages = {}, + onNavigateToNodeDetails = {}, + onNavigateToFilterSettings = {}, + scrollToTopEvents = null, + activeContactKey = null, + ) + } + } + onNodeWithText(CHANNEL_NAME).performTouchInput { longClick() } + waitForIdle() + } + + @Test + fun selectingAPinnedConversationOffersUnpin() = runComposeUiTest { + showContacts(ContactSettings(contactKey = channelKey, pinned = true)) + + onNodeWithContentDescription("Unpin").performClick() + waitUntil { pinWrites.value.isNotEmpty() } + assertEquals(listOf(false), pinWrites.value) + } + + @Test + fun selectingAnUnmutedConversationOffersMute() = runComposeUiTest { + showContacts(ContactSettings(contactKey = channelKey)) + + onNodeWithContentDescription("Mute selected").assertExists() + onNodeWithContentDescription("Pin").assertExists() + } + + private companion object { + const val CHANNEL_NAME = "Ops" + } +}