From 7b1f238b55bb66f015d9fa79d14cc28ac878d656 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 14:09:16 -0400 Subject: [PATCH 1/5] feat(chat): open the mute sheet from a swipe on the chat list A trailing swipe on a chat list row now opens the same mute sheet the DM and group profiles open. It never mutes on its own, because a mute needs a duration, and unmuting stays the sheet's "Never" row. The icon is a crossed-out bell on an audible chat and a plain bell on a muted one. The sheet was the ChatStep.MuteChat flow step. The step carried nothing and read the chat id and viewer state off the chat flow's ChatViewModel, which the chat list does not have. It is now a top-level AppRoute.Messaging.MuteChat(chatId) sheet, opened the way Report is, backed by a MuteChatViewModel that observes that chat's viewer state and calls ChatCoordinator.mute/unmute. Both profiles push the new route, and the step, its flow entry and ChatViewModel's MuteChat/UnmuteChat events are removed, so MuteChatSheet still has one caller. The sheet closes on the tap and its view model is scoped to the sheet's entry, so the request runs NonCancellable. Otherwise closing the sheet would cancel the call whose answer is written to the store and shown as the row's muted indicator. The list items are keyed by chat id, so a row's swipe state stays with its chat when new activity reorders the list. The row also exposes the action as an accessibility custom action, since a screen reader user cannot swipe. --- .../ui/navigation/AppScreenContent.kt | 2 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 19 ++++ .../com/flipcash/app/core/chat/ChatStep.kt | 17 +--- .../core/src/main/res/values/strings.xml | 4 + .../flipcash/app/messenger/ChatFlowScreen.kt | 37 +------- .../flipcash/app/messenger/MuteChatScreen.kt | 44 +++++++++ .../app/messenger/internal/ChatViewModel.kt | 49 +--------- .../messenger/internal/MuteChatViewModel.kt | 94 +++++++++++++++++++ .../screens/profile/ChatProfileScreen.kt | 8 +- .../screens/profile/GroupProfileScreen.kt | 8 +- .../com/flipcash/app/tipping/ChatsScreen.kt | 93 ++++++++++++++++-- 11 files changed, 264 insertions(+), 111 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MuteChatScreen.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index a83807714f..0f8ce18b8e 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -36,6 +36,7 @@ import com.flipcash.app.currency.RegionSelectionScreen import com.flipcash.app.deposit.DepositFlowScreen import com.flipcash.app.invite.InviteContactScreen import com.flipcash.app.messenger.ChatFlowScreen +import com.flipcash.app.messenger.MuteChatScreen import com.flipcash.app.messenger.ReportFlowScreen import com.flipcash.app.discovery.TokenDiscoveryScreen import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator @@ -123,6 +124,7 @@ fun appEntryProvider( annotatedEntry { key -> ReportFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } + annotatedEntry { key -> MuteChatScreen(key.chatId) } // Tokens annotatedEntry(testTag = "token_info_screen") { key -> diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 95d9ff50e5..826bb72b0a 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -7,6 +7,7 @@ import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.chat.ReportSubject import com.flipcash.app.core.reporting.ReportStep import com.flipcash.app.core.chat.NewGroupStep +import com.flipcash.services.models.chat.ChatId import com.flipcash.app.core.deposit.DepositResult import com.flipcash.app.core.deposit.DepositStep import com.flipcash.app.core.onboarding.OnboardingStep @@ -401,6 +402,24 @@ sealed interface AppRoute : NavKey, Parcelable { override val initialStack: List get() = listOf(ReportStep.ReasonSelection) } + + /** + * How long to mute [chatId] for. + * + * A top-level route carrying the chat's id, for the reason [Report] is one: it is opened + * both from inside a chat (the DM and group profiles) and from the chat list, which has no + * chat flow behind it to ask which chat is meant. One route for both keeps the sheet and + * the mute request in one place. + * + * A [com.getcode.navigation.WrapContentSheet]: five rows at most, and whatever opened it is + * where you end up again once one is picked. + */ + @Serializable + @Parcelize + data class MuteChat(val chatId: ChatId) : + Messaging, + com.getcode.navigation.Sheet, + com.getcode.navigation.WrapContentSheet } @Serializable diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index a4d24633c8..af9adcc605 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -54,19 +54,6 @@ sealed interface ChatStep : FlowStep, Parcelable { @Serializable data object InviteToGroup : ChatStep, Sheet, WrapContentSheet - /** - * How long to mute the conversation for. - * - * A [WrapContentSheet] for the same reason [InviteToGroup] is one: four rows, chosen from the - * group's profile, and the profile is where you end up again once one is picked. Carries - * nothing — the chat is the one the flow is already open on, and the shape the user picks - * travels back as an event rather than as a nav result, because muting is a request the view - * model makes rather than an answer the sheet returns. - */ - @Parcelize - @Serializable - data object MuteChat : ChatStep, Sheet, WrapContentSheet - @Parcelize @Serializable data class Profile(val contact: ChatParticipant): ChatStep @@ -86,8 +73,8 @@ sealed interface ChatStep : FlowStep, Parcelable { /** * Node 10187:110373 — what about the group can be changed, as a list of the things that can. * - * A pushed screen rather than a sheet, unlike [InviteToGroup] and [MuteChat]: those end on the - * choice they present, while this one is a way through to another screen and has to be + * A pushed screen rather than a sheet, unlike [InviteToGroup]: that ends on the choice it + * presents, while this one is a way through to another screen and has to be * somewhere back can return to. Carries nothing for the same reason [GroupProfile] does not — * the flow is already open on the group, and every step here reads it off the conversation's * view model. diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index fe0d3b04f5..929df5953a 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -939,6 +939,10 @@ same as a permanent one. --> Muted until %1$s Muted + + Mute notifications + Change mute Something Went Wrong We were unable to mute the chat. Please try again Something Went Wrong diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index cec34f2cb9..99764c26ee 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -24,7 +24,6 @@ import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.StartSendCashOnceReady import com.flipcash.app.messenger.internal.screens.GroupInviteSheet import com.flipcash.app.messenger.internal.screens.MessengerScreen -import com.flipcash.app.messenger.internal.screens.MuteChatSheet import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent import com.flipcash.app.messenger.internal.screens.cash.ChatInitPaymentSheet import com.flipcash.app.messenger.internal.screens.profile.ChatProfileScreen @@ -33,7 +32,6 @@ import com.flipcash.app.messenger.internal.screens.profile.GroupProfileScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupNameScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupPictureScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupScreen -import com.flipcash.shared.chat.ui.rememberIsMuted import com.getcode.navigation.annotatedEntry import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.FlowHost @@ -71,8 +69,7 @@ fun ChatFlowScreen( // Popping with the IME still up drags the screen behind it out from under the keyboard. onExit = { _, _ -> keyboard.hideIfVisible { navigator.pop() } }, entryProvider = chatEntryProvider(route.identifier, route.openKeyboard, route.openSendCash), - // ChatStep.AmountEntry, ChatStep.InitPayment, ChatStep.InviteToGroup and - // ChatStep.MuteChat are Sheets, so the + // ChatStep.AmountEntry, ChatStep.InitPayment and ChatStep.InviteToGroup are Sheets, so the // flow needs the sheet strategy to draw them as such; without it the step would fall // through to SinglePane and cover the thread. Amount entry // returns its result inside the flow (resultBackNavigator), so the strategy's own @@ -103,9 +100,6 @@ private fun chatEntryProvider( annotatedEntry { FlowGroupInviteSheet() } - annotatedEntry { - FlowMuteChatSheet() - } annotatedEntry { step -> FlowChatProfileScreen(step.contact) @@ -278,35 +272,6 @@ private fun FlowGroupInviteSheet() { ) } -@Composable -private fun FlowMuteChatSheet() { - val viewModel = flowSharedViewModel() - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - // Same dismissal rule as the other sheets in this flow: exit through the sheet so it animates - // down rather than having its scene deleted mid-frame. - val dismissSheet = LocalBottomSheetDismissDispatcher.current - - MuteChatSheet( - // Asked per composition rather than passed in as a boolean the caller computed: a timed - // mute lapses with nothing sent to say so, and the sheet's unmute row is what would - // otherwise be left standing over a chat that is already audible again. - isMuted = rememberIsMuted(state.viewerState), - // Dismissed on the tap rather than on the result: the request is fire-and-forget from here, - // and a failure is reported by the view model's own error bar, which draws over whatever is - // on screen by then. - onMute = { - viewModel.dispatchEvent(ChatViewModel.Event.MuteChat(it)) - dismissSheet() - }, - onUnmute = { - viewModel.dispatchEvent(ChatViewModel.Event.UnmuteChat) - dismissSheet() - }, - onDismiss = dismissSheet, - ) -} - - @Composable private fun FlowChatProfileScreen(participant: ChatParticipant) { val viewModel = flowSharedViewModel() diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MuteChatScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MuteChatScreen.kt new file mode 100644 index 0000000000..25240839e1 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MuteChatScreen.kt @@ -0,0 +1,44 @@ +package com.flipcash.app.messenger + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.messenger.internal.MuteChatViewModel +import com.flipcash.app.messenger.internal.screens.MuteChatSheet +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ui.rememberIsMuted +import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher + +/** + * The mute sheet for [chatId], behind `AppRoute.Messaging.MuteChat`, wherever it was opened from: + * a chat profile, a group profile, or a swipe on the chat list. + */ +@Composable +fun MuteChatScreen(chatId: ChatId) { + val viewModel = hiltViewModel() + val viewerStates = remember(viewModel, chatId) { viewModel.observeViewerState(chatId) } + val initial = remember(viewModel, chatId) { viewModel.currentViewerState(chatId) } + val viewerState by viewerStates.collectAsStateWithLifecycle(initialValue = initial) + // Exit through the sheet so it animates down rather than having its scene deleted mid-frame. + val dismissSheet = LocalBottomSheetDismissDispatcher.current + + MuteChatSheet( + // Asked per composition rather than passed in as a boolean the caller computed: a timed + // mute lapses with nothing sent to say so, and the sheet's unmute row is what would + // otherwise be left standing over a chat that is already audible again. + isMuted = rememberIsMuted(viewerState), + // Dismissed on the tap rather than on the result: the request is fire-and-forget from here, + // and a failure is reported by the error bar, which draws over whatever is on screen by then. + onMute = { + viewModel.mute(chatId, it) + dismissSheet() + }, + onUnmute = { + viewModel.unmute(chatId) + dismissSheet() + }, + onDismiss = dismissSheet, + ) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 2d545745dd..2a4433a2f1 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -45,7 +45,6 @@ import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.DeliveryStatus import com.flipcash.services.models.chat.MessageContent -import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.TypingState import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.chat.isDmAddressable @@ -479,17 +478,6 @@ internal class ChatViewModel @Inject constructor( /** This chat's viewer state moved, from the stream or from the viewer's own request. */ data class OnViewerStateResolved(val viewerState: ViewerState?) : Event - /** - * A shape picked from the mute sheet. Carries the whole [MuteState] rather than a duration - * because a deadline and forever are not the same kind of thing, and the sheet is the last - * place that can tell them apart. - * - * No confirmation, unlike the leave: muting is undone by the row that replaces it. - */ - data class MuteChat(val mute: MuteState) : Event - - /** The group profile's "Unmute Chat" row. Its own request, not a mute of zero length. */ - data object UnmuteChat : Event data class OnCurrencySymbolUpdated(val symbol: String): Event data class OnChatInitFeeUpdated(val formatted: String?) : Event data class OnSendCashReadinessChanged(val ready: Boolean) : Event @@ -1738,39 +1726,6 @@ internal class ChatViewModel @Inject constructor( } .launchIn(viewModelScope) - eventFlow.filterIsInstance() - .onEach { event -> - val chatId = stateFlow.value.chatId ?: return@onEach - // Nothing to dispatch on success: the stored viewer state is what the row reads, - // and `mute` writes it before returning, so the profile flips on the same flow the - // stream would have moved it on. - chatCoordinator.mute(chatId, event.mute) - .onFailure { - trace("failed to mute chat - ${it.localizedMessage}") - BottomBarManager.showError( - title = resources.getString(R.string.error_title_failedToMute), - message = resources.getString(R.string.error_description_failedToMute), - ) - } - } - .launchIn(viewModelScope) - - eventFlow.filterIsInstance() - .onEach { - val chatId = stateFlow.value.chatId ?: return@onEach - chatCoordinator.unmute(chatId) - .onFailure { - trace("failed to unmute chat - ${it.localizedMessage}") - BottomBarManager.showError( - title = resources.getString(R.string.error_title_failedToUnmute), - message = resources.getString( - R.string.error_description_failedToUnmute, - ), - ) - } - } - .launchIn(viewModelScope) - // A bubble is what the UI has; a citation is what the composer needs, and building one // reads the stored message. A message this device never stored drops the request rather // than opening a strip with nothing in it. @@ -2311,9 +2266,7 @@ internal class ChatViewModel @Inject constructor( Event.CopyInviteLink, Event.LeaveChat, Event.LeaveConfirmed, - Event.LeftChat, - is Event.MuteChat, - Event.UnmuteChat -> { state -> state } + Event.LeftChat -> { state -> state } is Event.OnTipUserResolved -> { state -> // A device contact, once matched, wins over the server profile (it carries the // phone number and the user's own naming). Otherwise this is a tip DM: adopt the diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt new file mode 100644 index 0000000000..980a898d1a --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt @@ -0,0 +1,94 @@ +package com.flipcash.app.messenger.internal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.ViewerState +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.trace +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** + * Backs the mute sheet for one chat, named by id rather than read off a chat flow's view model. + * + * The sheet is opened from the chat list as well as from the profiles, and the list has no + * conversation behind it, so everything here is keyed by the id the route carries. Previously the + * requests lived on `ChatViewModel`, which only the profiles could reach. + */ +@HiltViewModel +internal class MuteChatViewModel @Inject constructor( + private val chatCoordinator: ChatCoordinator, + private val resources: ResourceHelper, +) : ViewModel() { + + /** + * The viewer state the feed already holds for [chatId], read without waiting. + * + * Seeds [observeViewerState] so the sheet opens with its unmute row already decided. Starting + * from `null` would open it without the row and then grow it by one as the store answered. + */ + fun currentViewerState(chatId: ChatId): ViewerState? = + chatCoordinator.currentFeed(ChatType.TIP_DM, ChatType.GROUP) + ?.firstOrNull { it.metadata.chatId == chatId } + ?.metadata?.viewerState + + /** + * Observed rather than read once: a mute made on another device arrives on the stream, and a + * timed one can lapse under the open sheet. + */ + fun observeViewerState(chatId: ChatId): Flow = + chatCoordinator.observeMetadata(chatId) + .map { it?.metadata?.viewerState } + .distinctUntilChanged() + + /** + * Nothing to report on success: `mute` stores the viewer state it answers with, and every + * surface showing the mute reads that store. + */ + fun mute(chatId: ChatId, mute: MuteState) = request( + errorTitle = R.string.error_title_failedToMute, + errorDescription = R.string.error_description_failedToMute, + ) { + chatCoordinator.mute(chatId, mute) + } + + fun unmute(chatId: ChatId) = request( + errorTitle = R.string.error_title_failedToUnmute, + errorDescription = R.string.error_description_failedToUnmute, + ) { + chatCoordinator.unmute(chatId) + } + + private fun request( + errorTitle: Int, + errorDescription: Int, + call: suspend () -> Result, + ) { + viewModelScope.launch { + // The sheet closes on the tap, and this view model is scoped to the sheet's entry, so + // its scope is cancelled while the request is still in flight. Cancelling there would + // drop the answer the store is written from, and the error bar with it. + withContext(NonCancellable) { + call().onFailure { + trace("failed to change chat mute - ${it.localizedMessage}") + BottomBarManager.showError( + title = resources.getString(errorTitle), + message = resources.getString(errorDescription), + ) + } + } + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index 9bbc224a42..8d66667538 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -143,8 +143,12 @@ internal fun ChatProfileScreen( ChatProfileAction.Block -> viewModel.dispatchEvent(ChatProfileViewModel.Event.BlockUser) // Both muting and unmuting go through the picker, which is why this row - // navigates either way rather than acting on one of them here. - ChatProfileAction.Mute -> flowNavigator.navigateTo(ChatStep.MuteChat) + // navigates either way rather than acting on one of them here. The outer + // navigator, as with Report: the sheet is a top-level route shared with the + // chat list, so it opens over the chat rather than inside it. + ChatProfileAction.Mute -> chatState.chatId?.let { chatId -> + navigator.push(AppRoute.Messaging.MuteChat(chatId)) + } // Not flowNavigator: Report is a top-level route rather than a step of // this flow, and LocalCodeNavigator hands a non-FlowStep route up to its // parent. So it opens over the chat rather than inside it. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt index b2c28067a9..4aa5c060c8 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt @@ -141,8 +141,12 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { // it sits over the profile the user asked from. GroupProfileAction.Invite -> flowNavigator.navigateTo(ChatStep.InviteToGroup) // Both muting and unmuting go through the picker, which is why this row - // navigates either way rather than acting on one of them here. - GroupProfileAction.Mute -> flowNavigator.navigateTo(ChatStep.MuteChat) + // navigates either way rather than acting on one of them here. The outer + // navigator, as with Report: the sheet is a top-level route shared with the + // chat list, so it opens over the chat rather than inside it. + GroupProfileAction.Mute -> state.chatId?.let { chatId -> + navigator.push(AppRoute.Messaging.MuteChat(chatId)) + } GroupProfileAction.Leave -> viewModel.dispatchEvent(ChatViewModel.Event.LeaveChat) // The outer navigator, not this flow's: reporting is its own top-level diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index 526951387b..ccc9bb42a3 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -7,12 +7,17 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -23,6 +28,9 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -40,10 +48,13 @@ import com.flipcash.app.tipping.internal.ChatsViewModel import com.flipcash.app.tipping.internal.components.TipChatRow import com.flipcash.features.tipping.R import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.shared.chat.ui.rememberIsMuted import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.SwipeAction +import com.getcode.ui.components.SwipeActionRow import com.getcode.ui.core.verticalScrollStateGradient import com.getcode.ui.theme.CodeScaffold import com.getcode.ui.theme.ScaffoldBarPlacement @@ -122,9 +133,17 @@ fun ChatsScreen() { if (chats.isLoaded() && chats.data.isEmpty()) { item { NoChatsYet(Modifier.fillParentMaxSize()) } } else { - tipChatItems(chats.dataOrNull.orEmpty()) { chat -> - navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chat.chatId))) - } + tipChatItems( + chats = chats.dataOrNull.orEmpty(), + onClick = { chat -> + navigator.push( + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chat.chatId)) + ) + }, + // The same sheet the chat and group profiles open, so the list offers exactly + // the durations they do, and unmuting is its "Never" row rather than a toggle. + onMute = { chat -> navigator.push(AppRoute.Messaging.MuteChat(chat.chatId)) }, + ) } } } @@ -181,17 +200,75 @@ private fun NoChatsYet(modifier: Modifier = Modifier) { private fun LazyListScope.tipChatItems( chats: List, onClick: (ConversationReference) -> Unit, + onMute: (ConversationReference) -> Unit, ) { - itemsIndexed(chats) { index, chat -> - TipChatRow( - chat = chat, - showDivider = index < chats.lastIndex, + // Keyed by chat so a row's swipe state stays with its chat when new activity reorders the list. + itemsIndexed(chats, key = { _, chat -> chat.chatId }) { index, chat -> + MuteSwipeRow( + isMuted = rememberIsMuted(chat.viewerState), + onMute = { onMute(chat) }, + stateKey = chat.chatId, ) { - onClick(chat) + TipChatRow( + chat = chat, + showDivider = index < chats.lastIndex, + ) { + onClick(chat) + } } } } +/** + * A trailing swipe that opens the mute sheet, whichever way the chat is muted. + * + * The icon names the state the chat would be moved out of: a crossed-out bell on an audible chat, + * and a plain one on a muted chat, where the sheet is also how the mute is cleared. It never mutes + * directly, because a mute always needs a duration. + * + * Resets rather than settling open: a full swipe opens the sheet, and a row left swiped under it + * would still be sitting open once the sheet closed. + */ +@Composable +private fun MuteSwipeRow( + isMuted: Boolean, + onMute: () -> Unit, + stateKey: Any, + content: @Composable () -> Unit, +) { + val label = stringResource( + if (isMuted) R.string.content_description_changeMute + else R.string.content_description_muteChat + ) + SwipeActionRow( + actions = listOf( + SwipeAction( + // Neutral rather than the delete red: nothing is lost by it. + background = CodeTheme.colors.surfaceVariant, + onTriggered = onMute, + resetOnDismiss = true, + ) { + Icon( + imageVector = if (isMuted) { + Icons.Outlined.Notifications + } else { + Icons.Outlined.NotificationsOff + }, + contentDescription = label, + tint = CodeTheme.colors.textMain, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), + ) + } + ), + // A swipe is out of reach with a screen reader, so the row offers the same action there. + modifier = Modifier.semantics { + customActions = listOf(CustomAccessibilityAction(label) { onMute(); true }) + }, + stateKey = stateKey, + content = content, + ) +} + @Composable @Preview @PreviewWrapper(FlipcashThemeWrapper::class) From cdd854822988d044192029ab09cdcaf2f8cd262d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 15:28:36 -0400 Subject: [PATCH 2/5] fix(nav): let a wrap-content sheet size to its own content NavBillOverlayEntryDecorator wraps every root entry in a fillMaxSize Box to draw the bill overlay. Sheets get the Box too, so a sheet marked WrapContentSheet was stretched to the full expanded height. The mute sheet showed it once it moved to a root route; the flow-level step it replaced ran under a navigator without this decorator. The Box now fills only when the entry is not a wrap-content sheet. --- .../navigation/decorators/NavBillOverlayEntryDecorator.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavBillOverlayEntryDecorator.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavBillOverlayEntryDecorator.kt index 2a87c54f09..85ac12b99b 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavBillOverlayEntryDecorator.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavBillOverlayEntryDecorator.kt @@ -31,12 +31,15 @@ import com.getcode.navigation.scrim.ScrimOverlay @Suppress("FunctionName") fun NavBillOverlayEntryDecorator(): NavEntryDecorator { return NavEntryDecorator { entry -> - Box(modifier = Modifier.fillMaxSize()) { + val isSheet = entry.metadata[NavMetadataKeys.IsSheet.key] == true + // A wrap-content sheet is sized by what it measures, so filling here would stretch it to + // the full expanded height. + val wrapsContent = entry.metadata[NavMetadataKeys.IsWrapContentSheet.key] == true + Box(modifier = if (wrapsContent) Modifier else Modifier.fillMaxSize()) { entry.Content() // Sheets are painted above the base scene by NavDisplay, so they must NOT carry the // bill themselves — otherwise the bill would cover the sheet again. - val isSheet = entry.metadata[NavMetadataKeys.IsSheet.key] == true if (!isSheet) { ScrimOverlay(LocalScrimController.current) BillOverlay() From 346e559705cbf6a399886c30e1da679dcc6bb3d8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 15:28:37 -0400 Subject: [PATCH 3/5] fix(swipe): send a reset row home and let it fire on the next swipe A full swipe on a resetOnDismiss action is rejected, and the drag then settles on the nearest allowed anchor, Revealed, so the row stayed open under whatever the action opened. The row now animates back to Settled after the callback. The flag that fires the callback was cleared when that effect finished, but the drag's own settle can interrupt it, leaving the flag set. The next full swipe then wrote the same value, nothing relaunched, and the action never fired again. The flag is now re-armed when a drag starts, which still fires once per gesture: the settle confirms Dismissed on every frame it heads there. SwipeRevealGroup is new and optional: rows given the same group keep at most one open, closing the other as soon as a drag starts on another. --- .../getcode/ui/components/SwipeActionRow.kt | 64 ++++++++++++++++++- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeActionRow.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeActionRow.kt index da94f4022f..7ff1638336 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeActionRow.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeActionRow.kt @@ -2,6 +2,8 @@ package com.getcode.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.anchoredDraggable @@ -33,6 +35,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -73,6 +76,20 @@ class SwipeAction( private enum class SwipeState { Settled, Revealed, Dismissed } +/** + * Keeps at most one [SwipeActionRow] open among the rows given the same group: starting a swipe on + * one row closes whichever other row was left revealed. + * + * Rows are told apart by their `stateKey`, so give each row in a group a distinct one. + */ +@Stable +class SwipeRevealGroup { + internal var openKey: Any? by mutableStateOf(null) +} + +@Composable +fun rememberSwipeRevealGroup(): SwipeRevealGroup = remember { SwipeRevealGroup() } + /** * Convenience overload that wraps [content] with a single red delete action. */ @@ -124,6 +141,7 @@ fun SwipeActionRow( modifier: Modifier = Modifier, stateKey: Any? = null, initiallyRevealed: Boolean = false, + revealGroup: SwipeRevealGroup? = null, content: @Composable () -> Unit, ) { if (actions.isEmpty()) { @@ -163,11 +181,47 @@ fun SwipeActionRow( } var rowWidthPx by remember(stateKey) { mutableFloatStateOf(0f) } - // Fire callback once for rejected dismiss (reset case) + // Fire callback once for rejected dismiss (reset case), then send the row home. A rejected + // settle does not do that by itself: the drag settles on the nearest anchor it is allowed, + // which after a full swipe is Revealed, and the row stays open under whatever the callback + // opened. LaunchedEffect(pendingResetCallback) { if (pendingResetCallback) { currentDismissCallback() - pendingResetCallback = false + state.animateTo(SwipeState.Settled) + } + } + + val dragInteractions = remember { MutableInteractionSource() } + // The reset flag is re-armed by the next drag rather than by the effect above finishing. The + // settle asks to confirm Dismissed on every frame it heads there, so the flag has to stay set + // for the rest of the gesture to fire the callback once; and the effect can be left suspended + // by the settle interrupting its animation, which must not leave the flag stuck at true for + // the next swipe to write again without relaunching anything. + LaunchedEffect(dragInteractions) { + dragInteractions.interactions.collect { interaction -> + if (interaction is DragInteraction.Start) pendingResetCallback = false + } + } + if (revealGroup != null) { + val fallbackKey = remember { Any() } + val groupKey = stateKey ?: fallbackKey + // Claimed when the drag starts rather than when it settles, so the other row closes while + // this one opens. Keyed to the drag and not to the offset, which also moves while a row + // is closing and would have it claim the group straight back. + LaunchedEffect(dragInteractions, revealGroup, groupKey) { + dragInteractions.interactions.collect { interaction -> + if (interaction is DragInteraction.Start) revealGroup.openKey = groupKey + } + } + LaunchedEffect(state, revealGroup, groupKey) { + snapshotFlow { revealGroup.openKey }.collect { openKey -> + if (openKey != null && openKey != groupKey && + state.currentValue != SwipeState.Settled + ) { + state.animateTo(SwipeState.Settled) + } + } } } @@ -252,7 +306,11 @@ fun SwipeActionRow( Box( modifier = Modifier .offset { IntOffset(state.offset.toInt(), 0) } - .anchoredDraggable(state, Orientation.Horizontal), + .anchoredDraggable( + state = state, + orientation = Orientation.Horizontal, + interactionSource = dragInteractions, + ), ) { content() } From 46114a03a799360f78077e1581ca2ad2e64c978e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 15:28:37 -0400 Subject: [PATCH 4/5] feat(chat): amber swipe action, one open row, bell beside the name The mute action uses the profile's unverified badge treatment, warning at 15% behind a warning-tinted icon. The list shares a SwipeRevealGroup so swiping one row closes another left open. The muted bell moves from the end of the preview line to right after the chat name. The name takes one line and only the width it needs, so a long name ellipsizes before it pushes the bell or the timestamp off. --- .../com/flipcash/app/tipping/ChatsScreen.kt | 16 ++++- .../tipping/internal/components/TipChatRow.kt | 64 +++++++++---------- .../flipcash/shared/chat/ui/MutedIndicator.kt | 2 +- 3 files changed, 46 insertions(+), 36 deletions(-) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index ccc9bb42a3..0762b93c02 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -55,6 +55,8 @@ import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.SwipeAction import com.getcode.ui.components.SwipeActionRow +import com.getcode.ui.components.SwipeRevealGroup +import com.getcode.ui.components.rememberSwipeRevealGroup import com.getcode.ui.core.verticalScrollStateGradient import com.getcode.ui.theme.CodeScaffold import com.getcode.ui.theme.ScaffoldBarPlacement @@ -71,6 +73,8 @@ fun ChatsScreen() { val chats = state.chats val listState = rememberLazyListState() + // One row open at a time: swiping another row closes the one left revealed. + val revealGroup = rememberSwipeRevealGroup() CodeScaffold( // The list runs the full height and passes under the title bar, which fades it out against @@ -143,6 +147,7 @@ fun ChatsScreen() { // The same sheet the chat and group profiles open, so the list offers exactly // the durations they do, and unmuting is its "Never" row rather than a toggle. onMute = { chat -> navigator.push(AppRoute.Messaging.MuteChat(chat.chatId)) }, + revealGroup = revealGroup, ) } } @@ -201,6 +206,7 @@ private fun LazyListScope.tipChatItems( chats: List, onClick: (ConversationReference) -> Unit, onMute: (ConversationReference) -> Unit, + revealGroup: SwipeRevealGroup, ) { // Keyed by chat so a row's swipe state stays with its chat when new activity reorders the list. itemsIndexed(chats, key = { _, chat -> chat.chatId }) { index, chat -> @@ -208,6 +214,7 @@ private fun LazyListScope.tipChatItems( isMuted = rememberIsMuted(chat.viewerState), onMute = { onMute(chat) }, stateKey = chat.chatId, + revealGroup = revealGroup, ) { TipChatRow( chat = chat, @@ -234,6 +241,7 @@ private fun MuteSwipeRow( isMuted: Boolean, onMute: () -> Unit, stateKey: Any, + revealGroup: SwipeRevealGroup, content: @Composable () -> Unit, ) { val label = stringResource( @@ -243,8 +251,9 @@ private fun MuteSwipeRow( SwipeActionRow( actions = listOf( SwipeAction( - // Neutral rather than the delete red: nothing is lost by it. - background = CodeTheme.colors.surfaceVariant, + // The profile's unverified badge treatment rather than the delete red: nothing is + // lost by it. + background = CodeTheme.colors.warning.copy(alpha = 0.15f), onTriggered = onMute, resetOnDismiss = true, ) { @@ -255,7 +264,7 @@ private fun MuteSwipeRow( Icons.Outlined.NotificationsOff }, contentDescription = label, - tint = CodeTheme.colors.textMain, + tint = CodeTheme.colors.warning, modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), ) } @@ -265,6 +274,7 @@ private fun MuteSwipeRow( customActions = listOf(CustomAccessibilityAction(label) { onMute(); true }) }, stateKey = stateKey, + revealGroup = revealGroup, content = content, ) } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt index b0764547fd..77baa7e366 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt @@ -1,7 +1,6 @@ package com.flipcash.app.tipping.internal.components import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.requiredSize @@ -11,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import com.flipcash.app.theme.FlipcashThemeWrapper @@ -46,14 +46,28 @@ internal fun TipChatRow( ) }, title = { - Text( + Row( modifier = Modifier.weight(1f), - // Name, or the `@handle` when there isn't one — the row's single line of identity - // (node 9442:103645 has the preview under it, so there is nowhere else to put it). - text = chat.name.orEmpty(), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - ) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Text( + // Takes only what it needs, so the bell trails the name itself rather than + // the far edge, and a long name ellipsizes before it pushes the bell off. + modifier = Modifier.weight(1f, fill = false), + // Name, or the `@handle` when there isn't one — the row's single line of + // identity (node 9442:103645 has the preview under it, so there is nowhere + // else to put it). + text = chat.name.orEmpty(), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + // An audible chat emits no indicator at all, so the gap is not spent either. + MutedIndicator(viewerState = chat.viewerState) + } ChatRowTrailing( lastActivity = chat.lastActivity, @@ -62,31 +76,17 @@ internal fun TipChatRow( ) }, subtitle = { - // The mute sits at the end of this line rather than the one above it, where the - // timestamp and unread badge already compete for the trailing corner. It reads as the - // bottom of a column of chat-level state, and matches where iOS draws it. - Row( - verticalAlignment = Alignment.CenterVertically, - // An audible chat emits no indicator at all, so the gap is not spent either and - // the preview keeps the full width. - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), - ) { - Box(modifier = Modifier.weight(1f)) { - ChatRowSubtitle( - isTyping = chat.isTyping, - preview = chat.lastMessagePreview, - hasMessages = chat.hasMessages, - fallback = { - // Blank rather than absent: a chat with a message the row cannot - // preview still occupies both lines, so it doesn't jump in height - // against its neighbours. - SubtitleText("") - } - ) + ChatRowSubtitle( + isTyping = chat.isTyping, + preview = chat.lastMessagePreview, + hasMessages = chat.hasMessages, + fallback = { + // Blank rather than absent: a chat with a message the row cannot preview + // still occupies both lines, so it doesn't jump in height against its + // neighbours. + SubtitleText("") } - - MutedIndicator(viewerState = chat.viewerState) - } + ) }, showDivider = showDivider, onClick = onClick, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt index 7841929c7e..449d1db8b6 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt @@ -24,7 +24,7 @@ import kotlin.time.Clock * already wrong by the time the deadline passes; [rememberIsMuted] waits the deadline out and stops * drawing on its own. Emits nothing at all when the chat is audible, so a row pays no layout for it. * - * Sized and tinted to sit with the caption text it stands beside — the row's preview, the title + * Sized and tinted to sit quietly beside the name it follows — the chat list row's, the title * bar's name — rather than to be noticed on its own. It states a setting, it does not ask for * anything. Filled rather than outlined, matching iOS' `bell.slash.fill`. The mute row and the * profile chip stay outlined, as iOS' do — those sit at body size beside a label that already From 372cc4241ca03fa8e8df46ccf4751144da7a17eb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 15:38:05 -0400 Subject: [PATCH 5/5] fix(chat): fade the profile's mute chip out whole on unmute The chip's label went null the moment the mute cleared, while the chip was still fading out, so the text vanished and a bare bell faded on its own. The chip now keeps its last label through the exit. rememberIsMuted also held the previous answer for a frame after an unmute: produceState re-answers after composition, so the unmute drew one frame of "muted" with no deadline. It now reads the state in the same pass and only waits on the lapse of a timed mute. --- .../messenger/internal/ChatMuteStatusChip.kt | 11 +++++++++- .../flipcash/shared/chat/ui/MutedIndicator.kt | 20 +++++++++++++------ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt index a790708054..0ca9807bba 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt @@ -18,6 +18,10 @@ import androidx.compose.material.icons.outlined.NotificationsOff import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -62,6 +66,11 @@ internal fun ChatMuteStatusChip( reserveSpace: Boolean = true, ) { val label = rememberMutedLabel(viewerState) + // The label goes null the moment the mute clears, while the chip is still fading out. Drawing + // that would empty the text on the exit's first frame and fade out a bell on its own, so the + // exit keeps the last label it showed. + var shownLabel by remember { mutableStateOf(label.orEmpty()) } + if (label != null && label != shownLabel) shownLabel = label Box(modifier = modifier, contentAlignment = Alignment.Center) { // Holds the line's height whether or not there is a mute, so the rows below don't move the @@ -84,7 +93,7 @@ internal fun ChatMuteStatusChip( enter = fadeIn() + scaleIn(initialScale = 0.85f, animationSpec = spring()), exit = fadeOut(animationSpec = tween(durationMillis = 200)), ) { - Chip(label = label.orEmpty()) + Chip(label = shownLabel) } } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt index 449d1db8b6..f892534968 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt @@ -5,7 +5,11 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material3.Icon import androidx.compose.runtime.Composable -import androidx.compose.runtime.produceState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.flipcash.core.R @@ -61,11 +65,15 @@ fun MutedIndicator( @Composable fun rememberIsMuted(viewerState: ViewerState?): Boolean { val mute = viewerState?.mute - return produceState(initialValue = viewerState.isMutedAt(), mute) { - value = viewerState.isMutedAt() - val until = (mute as? MuteState.Until)?.until ?: return@produceState + // Only the lapse is waited for. The state itself is read in the same pass it changes in: a + // producer re-answering it would hold the previous answer for a frame, which on an unmute is a + // frame of "muted" with no deadline, drawn as a plain "Muted". + var lapsed by remember(mute) { mutableStateOf(false) } + LaunchedEffect(mute) { + val until = (mute as? MuteState.Until)?.until ?: return@LaunchedEffect val remaining = until - Clock.System.now() if (remaining.isPositive()) delay(remaining) - value = false - }.value + lapsed = true + } + return !lapsed && viewerState.isMutedAt() }