From 4d2d15d6b29a18ddf3aff4319f9477df97fa6f88 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 15:11:22 -0400 Subject: [PATCH 01/10] feat(chat): add the message selection bar, edit composer and delete Long-pressing a bubble raises a selection bar in place of the conversation title, offering exactly what the transcript already resolved for that message: delete inline, copy and edit under the overflow. The bar reads ContentBubble.capabilities rather than deciding anything itself, so a later group role taxonomy changes the resolver and not this surface. Editing takes over the composer rather than stacking a banner above it. Cancel replaces send-cash in the leading slot and the submit glyph animates to a checkmark, so the bar keeps its shape and the same control changes meaning. Whatever the composer held when the edit began is stashed and put back when the edit ends, whether by confirming, cancelling or backing out, and confirming an unchanged message exits without a request. TextBubble loses its SelectionContainer: its selection handles consumed the long-press before the row could see it. In-bubble text selection is gone as a result, and copying a message is the selection bar's Copy action instead. --- .../core/src/main/res/values/strings.xml | 17 ++ .../app/messenger/internal/ChatViewModel.kt | 179 ++++++++++++++- .../internal/screens/MessengerScreen.kt | 22 +- .../screens/components/ChatBottomBar.kt | 75 +++++- .../internal/screens/components/ChatTopBar.kt | 216 +++++++++++++++--- .../screens/components/MessageList.kt | 61 ++++- .../internal/ChatIdentityScreenshotTest.kt | 1 + .../internal/ChatMessageActionReducerTest.kt | 172 ++++++++++++++ .../ChatMessageActionScreenshotTest.kt | 147 ++++++++++++ apps/flipcash/shared/chat-ui/build.gradle.kts | 3 +- .../flipcash/shared/chat/models/ChatAction.kt | 12 + .../shared/chat/models/ChatListItem.kt | 23 ++ .../flipcash/shared/chat/ui/MessageBubble.kt | 20 +- .../chat/models/ChatListItemSelectionTest.kt | 74 ++++++ .../getcode/ui/components/chat/ChatInput.kt | 69 +++++- 15 files changed, 1020 insertions(+), 71 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionScreenshotTest.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemSelectionTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 089cdb6569..8b3c692e48 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -900,6 +900,23 @@ This message was deleted Message deleted + + Copy + Edit + Delete + More message actions + Clear selection + Delete for everyone + Cancel edit + Confirm edit + Delete message? + This can\'t be undone. + Message Not Edited + Your change couldn\'t be saved, so the message is unchanged. + Message Not Deleted + The message couldn\'t be deleted, so it is still visible to everyone. + Message + Unknown Contact Allow Full Contact Access Make sure you can send cash and identify people you know 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 f436c7de16..6fd431ef29 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 @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal +import android.content.ClipboardManager import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.snapshotFlow @@ -15,7 +16,11 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.app.core.extensions.setText import com.flipcash.app.core.ui.ConfirmationStyle +import com.flipcash.shared.chat.MessageCapability +import com.flipcash.shared.chat.applying +import com.flipcash.shared.chat.resolveCapabilities import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.models.ReceiptStatus import com.flipcash.shared.chat.models.SeparatorConfig @@ -105,6 +110,7 @@ internal class ChatViewModel @Inject constructor( private val userManager: UserManager, private val resources: ResourceHelper, private val analytics: FlipcashAnalyticsService, + private val clipboardManager: ClipboardManager, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -146,12 +152,39 @@ internal class ChatViewModel @Inject constructor( // open would be missed by the bottom bar before it subscribes, whereas state is durable // until the input is actually composed and can consume it. val messageInputRequested: Boolean = false, + /** + * The message the selection bar is acting on, or `null` when the ordinary title bar is up. + * + * One message at a time: every capability the transcript resolves — copy, edit, delete — + * applies to a single message, so a multi-selection would only ever be a bar with most of + * its actions disabled. + */ + val selection: ChatListItem.ContentBubble? = null, + /** The message the composer is editing, or `null` when it is composing a new one. */ + val editing: EditingMessage? = null, ) { // Opening the participant's profile (the entry point to blocking) is only available for tip DMs. val canViewProfile: Boolean get() = chatType == ChatType.TIP_DM + + /** What the selection bar may offer, straight from what the transcript already resolved. */ + val selectionCapabilities: Set + get() = selection?.capabilities.orEmpty() } + /** + * An edit in progress. + * + * [stashedDraft] is whatever the composer held when the edit began; leaving edit mode — by + * confirming, cancelling, or backing out — puts it back, so starting an edit never costs the + * user a half-written message. + */ + data class EditingMessage( + val messageId: Long, + val originalText: String, + val stashedDraft: String, + ) + sealed interface Event { data class OnChatOpened(val identifier: ChatIdentifier) : Event data class OnContactFound(val contact: DeviceContact): Event @@ -194,12 +227,36 @@ internal class ChatViewModel @Inject constructor( data class LimitsChanged(val limits: Limits?) : Event data class AdvanceReadPointer(val messageId: Long) : Event data class ChatDeactivated(val isReadOnly: Boolean) : Event + + /** Selects [bubble], or leaves selection mode if it is already the selected one. */ + data class ToggleMessageSelection(val bubble: ChatListItem.ContentBubble) : Event + data object ClearMessageSelection : Event + + // The message actions carry what they act on rather than reading it back off the selection: + // the reducer runs before the handlers do, so an action that dismisses the selection bar + // would otherwise have cleared its own subject before the handler saw it. + data class CopyMessage(val text: String) : Event + data class EditMessage(val messageId: Long, val text: String) : Event + data class DeleteMessage(val messageId: Long) : Event + + data object SubmitEdit : Event + data object CancelEdit : Event + data object EditingEnded : Event } @OptIn(ExperimentalCoroutinesApi::class) private val messageStream = stateFlow.mapNotNull { it.chatId } .distinctUntilChanged() .flatMapLatest { chatCoordinator.observeMessagesPaged(it) } + // Cached here rather than after the mapping below so the overlay composes over the page + // cache: an edit or delete awaiting the server re-runs the mapping without re-fetching. + .cachedIn(viewModelScope) + + /** Edits and deletes the server has not answered yet, composed over the stored transcript. */ + @OptIn(ExperimentalCoroutinesApi::class) + private val pendingMutations = stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatCoordinator.observePendingMutations(it) } @OptIn(ExperimentalCoroutinesApi::class) val otherReadPointer = stateFlow.mapNotNull { it.chatId } @@ -208,9 +265,10 @@ internal class ChatViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) @OptIn(ExperimentalCoroutinesApi::class) - val messages: Flow> = messageStream - .map { pagingData -> - pagingData.flatMap { message -> + val messages: Flow> = + combine(messageStream, pendingMutations) { pagingData, mutations -> + pagingData.flatMap { stored -> + val message = stored.applying(mutations[stored.messageId]) message.content.mapIndexed { index, content -> val enriched = if (content is MessageContent.Cash && content.tokenName.isBlank()) { val token = tokenCoordinator.getTokenMetadata(content.mint).getOrNull()?.token @@ -239,6 +297,10 @@ internal class ChatViewModel @Inject constructor( // A null author is a moderation removal, which reads as someone else's. deletedByViewer = (enriched as? MessageContent.Deleted)?.deletedBy ?.let { it == userManager.accountId } == true, + // Resolved once, here, so no menu re-derives it: a later group-role + // taxonomy becomes another input to the resolver rather than a branch at + // each action site. + capabilities = resolveCapabilities(message), ) } }.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? -> @@ -247,7 +309,7 @@ internal class ChatViewModel @Inject constructor( ChatListItem.DateSeparator(before.timestamp) } else null } - }.cachedIn(viewModelScope) + } private val maxAmountFlow by lazy { combine( @@ -364,6 +426,7 @@ internal class ChatViewModel @Inject constructor( initTokenAndExchangeObservers() initTypingHandlers() initSendHandlers() + initMessageActionHandlers() } } @@ -623,6 +686,90 @@ internal class ChatViewModel @Inject constructor( .launchIn(viewModelScope) } + private fun initMessageActionHandlers() { + eventFlow.filterIsInstance() + .onEach { event -> + clipboardManager.setText( + text = event.text, + label = resources.getString(R.string.title_clipboardLabelMessage), + ) + } + .launchIn(viewModelScope) + + // Pre-filling the composer writes to the live TextFieldState, so it runs on the main + // thread for the same reason clearing it after a send does. + eventFlow.filterIsInstance() + .onEach { event -> stateFlow.value.chatInputState.setTextAndPlaceCursorAtEnd(event.text) } + .flowOn(Dispatchers.Main.immediate) + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { + val editing = stateFlow.value.editing ?: return@onEach + val chatId = stateFlow.value.chatId ?: return@onEach + val text = stateFlow.value.chatInputState.text.toString() + finishEditing(editing) + + // Confirming an unchanged edit is still a way out of edit mode; it just isn't a + // request. An empty body isn't an edit either — deleting is the other action. + if (text.isBlank() || text == editing.originalText) return@onEach + + viewModelScope.launch { + chatCoordinator.editMessage(chatId, editing.messageId, text) + .onFailure { cause -> + trace("failed to edit message - ${cause.localizedMessage}") + BottomBarManager.showError( + title = resources.getString(R.string.title_messageNotEdited), + message = resources.getString(R.string.description_messageNotEdited), + ) + } + } + } + .flowOn(Dispatchers.Main.immediate) + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { finishEditing(stateFlow.value.editing ?: return@onEach) } + .flowOn(Dispatchers.Main.immediate) + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { event -> + val chatId = stateFlow.value.chatId ?: return@onEach + BottomBarManager.showAlert( + title = resources.getString(R.string.title_deleteMessage), + message = resources.getString(R.string.description_deleteMessage), + actions = listOf( + BottomBarAction( + text = resources.getString(R.string.action_deleteForEveryone), + ) { + // The selection bar stays up behind the sheet, so cancelling returns + // to it with the message still selected; only deleting ends selection. + dispatchEvent(Event.ClearMessageSelection) + viewModelScope.launch { + chatCoordinator.deleteMessage(chatId, event.messageId) + .onFailure { cause -> + trace("failed to delete message - ${cause.localizedMessage}") + BottomBarManager.showError( + title = resources.getString(R.string.title_messageNotDeleted), + message = resources.getString(R.string.description_messageNotDeleted), + ) + } + } + }, + ), + showCancel = true, + ) + } + .launchIn(viewModelScope) + } + + /** Leaves edit mode, restoring the draft the edit interrupted. */ + private fun finishEditing(editing: EditingMessage) { + stateFlow.value.chatInputState.setTextAndPlaceCursorAtEnd(editing.stashedDraft) + dispatchEvent(Event.EditingEnded) + } + private fun initSendHandlers() { // Send text message eventFlow.filterIsInstance() @@ -1012,6 +1159,30 @@ internal class ChatViewModel @Inject constructor( is Event.LimitsChanged -> { state -> state.copy(limits = event.limits) } is Event.AdvanceReadPointer -> { state -> state } is Event.ChatDeactivated -> { state -> state.copy(isAnonymous = event.isReadOnly) } + is Event.ToggleMessageSelection -> { state -> + val alreadySelected = state.selection?.itemKey == event.bubble.itemKey + state.copy(selection = event.bubble.takeUnless { alreadySelected }) + } + Event.ClearMessageSelection -> { state -> state.copy(selection = null) } + is Event.CopyMessage -> { state -> state.copy(selection = null) } + is Event.EditMessage -> { state -> + state.copy( + selection = null, + editing = EditingMessage( + messageId = event.messageId, + originalText = event.text, + // Starting a second edit before the first ends must not stash the + // first edit's text as if it were the user's draft. + stashedDraft = state.editing?.stashedDraft + ?: state.chatInputState.text.toString(), + ), + ) + } + // Selection survives the confirmation sheet; the delete action clears it. + is Event.DeleteMessage -> { state -> state } + Event.SubmitEdit -> { state -> state } + Event.CancelEdit -> { state -> state } + Event.EditingEnded -> { state -> state.copy(editing = null) } } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index c56dc3e58b..73a34f6623 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal.screens +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.runtime.Composable @@ -61,6 +62,16 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } + is ChatAction.ToggleSelection -> { + viewModel.dispatchEvent( + ChatViewModel.Event.ToggleMessageSelection(action.bubble) + ) + } + + ChatAction.ClearSelection -> { + viewModel.dispatchEvent(ChatViewModel.Event.ClearMessageSelection) + } + is ChatAction.ViewProfile -> { // The triggers (top-bar tap, contact-card chevron) are only clickable for tip DMs // (see State.canViewProfile), so no gating is needed here. @@ -75,13 +86,22 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { Unit } + // Back unwinds the message actions before it leaves the conversation, innermost first: an edit + // in progress, then the selection bar. + BackHandler(enabled = state.editing != null) { + viewModel.dispatchEvent(ChatViewModel.Event.CancelEdit) + } + BackHandler(enabled = state.editing == null && state.selection != null) { + viewModel.dispatchEvent(ChatViewModel.Event.ClearMessageSelection) + } + CodeScaffold( // The input bar rides the keyboard; the message list is inset by it either way. modifier = Modifier.imePadding(), // The list runs the full height and passes under both bars, each of which fades it out // against the background at its own edge. barPlacement = ScaffoldBarPlacement.Overlay, - topBar = { ChatTopBar(navigator, state, chatActionHandler) }, + topBar = { ChatTopBar(navigator, state, chatActionHandler, viewModel::dispatchEvent) }, bottomBar = { UserControlBottomBar( state = state, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index eb5cb876f9..ffd37a2e82 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -9,15 +9,22 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable 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.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -28,7 +35,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -154,16 +163,25 @@ internal fun UserControlBottomBar( horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), verticalAlignment = Alignment.Bottom, ) { - SendCashButton( - state = state, - hazeState = hazeState, - hazeMaterial = material, - onClick = { - keyboard.hideIfVisible { - dispatch(ChatViewModel.Event.OnSendCash) + // Editing swaps the leading control rather than adding a banner above the bar: + // send-cash is not reachable mid-edit anyway, and cancel is what the slot is + // for while the edit is open. + if (state.editing != null) { + CancelEditButton( + onClick = { dispatch(ChatViewModel.Event.CancelEdit) }, + ) + } else { + SendCashButton( + state = state, + hazeState = hazeState, + hazeMaterial = material, + onClick = { + keyboard.hideIfVisible { + dispatch(ChatViewModel.Event.OnSendCash) + } } - } - ) + ) + } if (canType) { ChatInput( @@ -179,12 +197,26 @@ internal fun UserControlBottomBar( focusRequester = focusRequester, hint = "Message", state = state.chatInputState, + isEditing = state.editing != null, onSendMessage = { - dispatch(ChatViewModel.Event.SendMessage) + if (state.editing != null) { + dispatch(ChatViewModel.Event.SubmitEdit) + } else { + dispatch(ChatViewModel.Event.SendMessage) + } keyboard.restartInput() }, ) + // An edit starts from a long-press, which leaves the keyboard down, so the + // composer has to claim focus itself or the pre-filled text sits unreachable. + LaunchedEffect(state.editing?.messageId) { + if (state.editing != null) { + focusRequester.requestFocus() + keyboard.show() + } + } + // Restores the pre-#1075 behavior: when OnStartMessageInput raises // state.messageInputRequested (returning from amount entry after a send, or a // post-tip open), focus the input and show the keyboard. Co-located with @@ -204,6 +236,29 @@ internal fun UserControlBottomBar( } } +/** Leaves edit mode. Sized to the send-cash button it stands in for so the bar doesn't reflow. */ +@Composable +private fun CancelEditButton(onClick: () -> Unit) { + val shape = CodeTheme.shapes.medium + Box( + modifier = Modifier + .defaultMinSize(minWidth = 54.dp, minHeight = 54.dp) + .clip(shape) + .background(Color.White.copy(alpha = 0.1f), shape) + .border(CodeTheme.dimens.border, CodeTheme.colors.divider, shape) + .clickable(onClick = onClick) + .testTag("chat_cancel_edit"), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = stringResource(R.string.action_cancelEdit), + tint = Color.White, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), + ) + } +} + @Composable private fun DeactivatedChatBottomBar() { Box( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index b3588c83de..f5853715a8 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -1,5 +1,9 @@ package com.flipcash.app.messenger.internal.screens.components +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -8,6 +12,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -19,14 +29,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.features.messenger.R +import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.models.ChatAction import com.flipcash.shared.chat.models.ChatActionHandler +import com.flipcash.shared.chat.models.ChatListItem import com.getcode.navigation.core.CodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.CircularIconButton import com.getcode.ui.core.measured import com.getcode.ui.core.unboundedClickable @@ -35,6 +50,7 @@ internal fun ChatTopBar( navigator: CodeNavigator, state: ChatViewModel.State, chatActionHandler: ChatActionHandler, + dispatch: (ChatViewModel.Event) -> Unit, ) { var titleHeight by remember { mutableStateOf(0.dp) } val bgColor = CodeTheme.colors.background @@ -52,45 +68,175 @@ internal fun ChatTopBar( ) ) ) - AppBarWithTitle( + // Selecting a message takes over the bar rather than stacking a second one over it, so the + // conversation's own actions can't be reached while a message action is pending. + AnimatedContent( modifier = Modifier.measured { titleHeight = it.height }, - leftIcon = { - AppBarDefaults.UpNavigation { navigator.pop() } - }, - title = { - Row( - // Profile open is only available for tip DMs (see State.canViewProfile). - modifier = Modifier - .fillMaxWidth() - .then( - if (state.canViewProfile) { - Modifier.unboundedClickable { - chatActionHandler(ChatAction.ViewProfile) - } - } else { - Modifier + targetState = state.selection, + contentKey = { it != null }, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "chat top bar", + ) { selection -> + if (selection == null) { + ConversationTitleBar(navigator, state, chatActionHandler) + } else { + MessageSelectionBar(selection, dispatch) + } + } + } +} + +@Composable +private fun ConversationTitleBar( + navigator: CodeNavigator, + state: ChatViewModel.State, + chatActionHandler: ChatActionHandler, +) { + AppBarWithTitle( + leftIcon = { + AppBarDefaults.UpNavigation { navigator.pop() } + }, + title = { + Row( + // Profile open is only available for tip DMs (see State.canViewProfile). + modifier = Modifier + .fillMaxWidth() + .then( + if (state.canViewProfile) { + Modifier.unboundedClickable { + chatActionHandler(ChatAction.ViewProfile) } - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - ) { - ParticipantAvatar( - participant = state.participant, - modifier = Modifier - .requiredSize(CodeTheme.dimens.staticGrid.x8) - .clip(CircleShape), - ) + } else { + Modifier + } + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + ParticipantAvatar( + participant = state.participant, + modifier = Modifier + .requiredSize(CodeTheme.dimens.staticGrid.x8) + .clip(CircleShape), + ) + + Text( + modifier = Modifier.weight(1f), + // Name-or-handle: the bar is one line (node 9443:9094), and the handle is + // the only identity a name-less tip DM counterparty has. + text = state.participant?.name.orEmpty(), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + } + } + ) +} + +/** + * The bar a long-press puts up, offering exactly what the transcript resolved for that message. + * + * Delete sits inline and the rest go under the overflow, following WhatsApp: the destructive action + * is the one worth a dedicated target, and burying it would make the common case a two-tap one. + */ +@Composable +private fun MessageSelectionBar( + selection: ChatListItem.ContentBubble, + dispatch: (ChatViewModel.Event) -> Unit, +) { + val capabilities = selection.capabilities + val body = selection.plainText - Text( - modifier = Modifier.weight(1f), - // Name-or-handle: the bar is one line (node 9443:9094), and the handle is - // the only identity a name-less tip DM counterparty has. - text = state.participant?.name.orEmpty(), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, + AppBarWithTitle( + leftIcon = { + CircularIconButton( + onClick = { dispatch(ChatViewModel.Event.ClearMessageSelection) }, + testTag = "action_clear_message_selection", + ) { size -> + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource(R.string.action_clearMessageSelection), + tint = Color.White, + modifier = Modifier.requiredSize(size), + ) + } + }, + title = { + // One message at a time today, but the count is what the bar shows and what a later + // multi-selection would change, so it renders as a count rather than a fixed label. + AppBarDefaults.Title(text = "1") + }, + rightContents = { + if (MessageCapability.Delete in capabilities) { + CircularIconButton( + onClick = { dispatch(ChatViewModel.Event.DeleteMessage(selection.messageId)) }, + testTag = "action_delete_message", + ) { size -> + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.action_delete), + tint = Color.White, + modifier = Modifier.requiredSize(size), ) } } - ) + + if (body != null) { + MessageOverflow( + capabilities = capabilities, + onCopy = { dispatch(ChatViewModel.Event.CopyMessage(body)) }, + onEdit = { dispatch(ChatViewModel.Event.EditMessage(selection.messageId, body)) }, + ) + } + }, + ) +} + +@Composable +private fun MessageOverflow( + capabilities: Set, + onCopy: () -> Unit, + onEdit: () -> Unit, +) { + val canCopy = MessageCapability.Copy in capabilities + val canEdit = MessageCapability.Edit in capabilities + if (!canCopy && !canEdit) return + + var expanded by remember { mutableStateOf(false) } + Box { + AppBarDefaults.Overflow(onClick = { expanded = true }) + DropdownMenu( + expanded = expanded, + containerColor = CodeTheme.colors.brandLight, + onDismissRequest = { expanded = false }, + ) { + if (canCopy) { + DropdownMenuItem( + text = { OverflowLabel(stringResource(R.string.action_copy)) }, + onClick = { + expanded = false + onCopy() + }, + ) + } + if (canEdit) { + DropdownMenuItem( + text = { OverflowLabel(stringResource(R.string.action_edit)) }, + onClick = { + expanded = false + onEdit() + }, + ) + } + } } -} \ No newline at end of file +} + +@Composable +private fun OverflowLabel(text: String) { + Text( + text = text, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + ) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 92ca4bf553..98dd9bfa89 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -2,9 +2,13 @@ package com.flipcash.app.messenger.internal.screens.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterTransition +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState import com.flipcash.app.messenger.internal.screens.ChatAnimations +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -27,6 +31,10 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput @@ -56,6 +64,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapNotNull +@OptIn(ExperimentalFoundationApi::class) @Composable internal fun MessageList( modifier: Modifier = Modifier, @@ -180,9 +189,59 @@ internal fun MessageList( } } + val bubble = item as? ChatListItem.ContentBubble + val isSelected = bubble != null && state.selection?.itemKey == bubble.itemKey + val bandColor by animateColorAsState( + targetValue = if (isSelected) { + CodeTheme.colors.chat.outgoingBubble.background.copy(alpha = 0.35f) + } else { + Color.Transparent + }, + label = "selectionBand", + ) + val bandBleed = CodeTheme.dimens.inset + val interactionSource = remember { MutableInteractionSource() } + Box( modifier = Modifier - .padding(bottom = bottomSpacing), + .padding(bottom = bottomSpacing) + .then( + if (bubble == null) Modifier else Modifier + .drawBehind { + if (bandColor == Color.Transparent) return@drawBehind + // The list insets its content, so the band is drawn back out + // into that inset to reach both edges of the screen. + val bleed = bandBleed.toPx() + drawRect( + color = bandColor, + topLeft = Offset(-bleed, 0f), + size = Size(size.width + bleed * 2, size.height), + ) + } + // Long-press is the whole row's gesture, not the bubble's: the + // selection band covers the row, and a target that small is worse + // to hit than the one the user already sees highlighted. + .combinedClickable( + interactionSource = interactionSource, + indication = null, + onLongClick = if (bubble.isSelectable) { + { + vibrator.tick() + onAction(ChatAction.ToggleSelection(bubble)) + } + } else null, + onClick = { + when { + // In selection mode a tap continues the selection + // rather than dismissing the keyboard. + state.selection == null -> keyboard.hide() + bubble.isSelectable -> + onAction(ChatAction.ToggleSelection(bubble)) + else -> onAction(ChatAction.ClearSelection) + } + }, + ) + ), ) { when (item) { is ChatListItem.DateSeparator -> Box(insertionModifier) { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt index 562ff695ab..588acd3ab8 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt @@ -116,6 +116,7 @@ class ChatIdentityScreenshotTest { }, ), chatActionHandler = {}, + dispatch = {}, ) } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt new file mode 100644 index 0000000000..3db8af379f --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt @@ -0,0 +1,172 @@ +package com.flipcash.app.messenger.internal + +import androidx.compose.foundation.text.input.TextFieldState +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.MessageCapability +import com.flipcash.shared.chat.models.ChatListItem +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.time.Instant + +/** + * The selection bar and the composer takeover are both plain state, so the reducer is where their + * rules live: one message selected at a time, and an edit that never costs the user their draft. + */ +class ChatMessageActionReducerTest { + + private val sentAt = Instant.fromEpochSeconds(1_000) + + private fun bubble( + messageId: Long, + text: String = "hello", + capabilities: Set = setOf( + MessageCapability.Copy, + MessageCapability.Edit, + MessageCapability.Delete, + ), + ) = ChatListItem.ContentBubble( + messageId = messageId, + contentIndex = 0, + content = MessageContent.Text(text), + isFromSelf = true, + timestamp = sentAt, + capabilities = capabilities, + ) + + private fun reduce( + state: ChatViewModel.State, + event: ChatViewModel.Event, + ): ChatViewModel.State = ChatViewModel.updateStateForEvent(event)(state) + + @Test + fun `long-pressing a bubble selects it`() { + val target = bubble(1) + + val state = reduce( + ChatViewModel.State(), + ChatViewModel.Event.ToggleMessageSelection(target), + ) + + assertSame(target, state.selection) + assertEquals(target.capabilities, state.selectionCapabilities) + } + + @Test + fun `long-pressing the selected bubble again clears the bar`() { + val target = bubble(1) + val selected = reduce( + ChatViewModel.State(), + ChatViewModel.Event.ToggleMessageSelection(target), + ) + + val state = reduce(selected, ChatViewModel.Event.ToggleMessageSelection(target)) + + assertNull(state.selection) + assertEquals(emptySet(), state.selectionCapabilities) + } + + @Test + fun `selecting another bubble replaces the selection rather than adding to it`() { + val first = bubble(1) + val second = bubble(2, text = "goodbye") + val selected = reduce( + ChatViewModel.State(), + ChatViewModel.Event.ToggleMessageSelection(first), + ) + + val state = reduce(selected, ChatViewModel.Event.ToggleMessageSelection(second)) + + assertSame(second, state.selection) + } + + @Test + fun `copying clears the bar`() { + val selected = reduce( + ChatViewModel.State(), + ChatViewModel.Event.ToggleMessageSelection(bubble(1)), + ) + + val state = reduce(selected, ChatViewModel.Event.CopyMessage("hello")) + + assertNull(state.selection) + } + + @Test + fun `the selection survives the delete confirmation`() { + // Cancelling the sheet has to land back on the bar it was opened from, so only the + // confirmed delete clears the selection. + val target = bubble(1) + val selected = reduce( + ChatViewModel.State(), + ChatViewModel.Event.ToggleMessageSelection(target), + ) + + val state = reduce(selected, ChatViewModel.Event.DeleteMessage(target.messageId)) + + assertSame(target, state.selection) + } + + @Test + fun `starting an edit takes over the composer and stashes the draft`() { + val target = bubble(1) + val selected = reduce( + ChatViewModel.State(chatInputState = TextFieldState("half-written")), + ChatViewModel.Event.ToggleMessageSelection(target), + ) + + val state = reduce( + selected, + ChatViewModel.Event.EditMessage(target.messageId, "hello"), + ) + + assertNull(state.selection) + val editing = assertNotNull(state.editing) + assertEquals(1L, editing.messageId) + assertEquals("hello", editing.originalText) + assertEquals("half-written", editing.stashedDraft) + } + + @Test + fun `editing a second message keeps the original draft rather than the first edit's text`() { + val first = reduce( + ChatViewModel.State(chatInputState = TextFieldState("half-written")), + ChatViewModel.Event.EditMessage(1, "hello"), + ) + // The composer now holds the first message's body, which is not the user's draft. + val midEdit = first.copy(chatInputState = TextFieldState("hello")) + + val state = reduce(midEdit, ChatViewModel.Event.EditMessage(2, "goodbye")) + + val editing = assertNotNull(state.editing) + assertEquals(2L, editing.messageId) + assertEquals("goodbye", editing.originalText) + assertEquals("half-written", editing.stashedDraft) + } + + @Test + fun `ending an edit releases the composer`() { + // Confirm, cancel and back all land here; restoring the stashed draft is the handler's job. + val editing = reduce( + ChatViewModel.State(chatInputState = TextFieldState("half-written")), + ChatViewModel.Event.EditMessage(1, "hello"), + ) + + val state = reduce(editing, ChatViewModel.Event.EditingEnded) + + assertNull(state.editing) + } + + @Test + fun `submitting and cancelling leave the edit in place for the handler to read`() { + val editing = reduce( + ChatViewModel.State(), + ChatViewModel.Event.EditMessage(1, "hello"), + ) + + assertNotNull(reduce(editing, ChatViewModel.Event.SubmitEdit).editing) + assertNotNull(reduce(editing, ChatViewModel.Event.CancelEdit).editing) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionScreenshotTest.kt new file mode 100644 index 0000000000..aba0aacecd --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionScreenshotTest.kt @@ -0,0 +1,147 @@ +package com.flipcash.app.messenger.internal + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.flipcash.app.messenger.internal.screens.components.ChatTopBar +import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.MessageCapability +import com.flipcash.shared.chat.models.ChatListItem +import com.getcode.navigation.core.CodeNavigator +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File +import kotlin.time.Instant + +/** + * Renders the selection bar in each capability shape to a PNG so the bar's layout can be checked + * against WhatsApp without an emulator. Not an assertion test — it writes to `build/screenshots/`. + * + * Same mechanics as `ChatIdentityScreenshotTest`: pause the clock, pump a fixed number of frames, + * and draw the Android view directly. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w400dp-h1100dp-xhdpi") +class ChatMessageActionScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private val sentAt = Instant.fromEpochSeconds(1_000) + + private fun bubble( + isFromSelf: Boolean, + capabilities: Set, + ) = ChatListItem.ContentBubble( + messageId = 1, + contentIndex = 0, + content = MessageContent.Text("hello"), + isFromSelf = isFromSelf, + timestamp = sentAt, + capabilities = capabilities, + ) + + @Test + fun rendersSelectionBarCapabilityStates() { + val navigator = mockk(relaxed = true) + // Own recent message: delete inline, copy and edit under the overflow. + val ownMessage = bubble( + isFromSelf = true, + capabilities = setOf( + MessageCapability.Copy, + MessageCapability.Reply, + MessageCapability.Edit, + MessageCapability.Delete, + ), + ) + // Someone else's message, and an own message past the edit window: overflow only, and the + // bar has to close up rather than leave a gap where delete or edit would have been. + val theirMessage = bubble( + isFromSelf = false, + capabilities = setOf(MessageCapability.Copy, MessageCapability.Reply), + ) + val ownStaleMessage = bubble( + isFromSelf = true, + capabilities = setOf( + MessageCapability.Copy, + MessageCapability.Reply, + MessageCapability.Delete, + ), + ) + + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column( + modifier = Modifier.width(360.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + listOf(ownMessage, theirMessage, ownStaleMessage).forEach { selection -> + ChatTopBar( + navigator = navigator, + state = ChatViewModel.State( + chatType = ChatType.CONTACT_DM, + selection = selection, + ), + chatActionHandler = {}, + dispatch = {}, + ) + } + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("chat_selection_bar.png") + } + + private fun capture(name: String) { + val root: View = composeRule.activity.findViewById(android.R.id.content) + val width = root.width.takeIf { it > 0 } ?: 1080 + val height = root.height.takeIf { it > 0 } ?: 1920 + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val cropped = bitmap.trimmedToDrawnArea() + + val outDir = File("build/screenshots").apply { mkdirs() } + val file = File(outDir, name) + file.outputStream().use { cropped.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${cropped.width}x${cropped.height})") + } + + /** Crop away the untouched (fully transparent) margin so the PNG is just what was composed. */ + private fun Bitmap.trimmedToDrawnArea(): Bitmap { + val pixels = IntArray(width * height) + getPixels(pixels, 0, width, 0, 0, width, height) + var left = width + var top = height + var right = -1 + var bottom = -1 + for (y in 0 until height) { + for (x in 0 until width) { + if (pixels[y * width + x] ushr 24 == 0) continue + if (x < left) left = x + if (x > right) right = x + if (y < top) top = y + if (y > bottom) bottom = y + } + } + if (right < left || bottom < top) return this + return Bitmap.createBitmap(this, left, top, right - left + 1, bottom - top + 1) + } +} diff --git a/apps/flipcash/shared/chat-ui/build.gradle.kts b/apps/flipcash/shared/chat-ui/build.gradle.kts index d74c220448..21f7b8da41 100644 --- a/apps/flipcash/shared/chat-ui/build.gradle.kts +++ b/apps/flipcash/shared/chat-ui/build.gradle.kts @@ -9,7 +9,8 @@ android { dependencies { implementation(project(":apps:flipcash:core-ui")) implementation(project(":apps:flipcash:core")) - implementation(project(":apps:flipcash:shared:chat")) + // api: ChatListItem.ContentBubble.capabilities exposes MessageCapability to consumers. + api(project(":apps:flipcash:shared:chat")) implementation(project(":ui:core")) implementation(project(":ui:components")) implementation(project(":ui:theme")) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index 117a14aa80..0af65a2a75 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -9,6 +9,18 @@ sealed interface ChatAction { object RefreshContact : ChatAction data class ViewToken(val mint: Mint) : ChatAction data object ViewProfile : ChatAction + + /** + * Adds [bubble] to the selection, or removes it if it is already selected. + * + * The bubble travels whole rather than as an id because the selection bar needs what the + * transcript already resolved — the capability set and the body to copy or edit — and re-reading + * it from the paging list would mean deciding availability at the menu instead. + */ + data class ToggleSelection(val bubble: ChatListItem.ContentBubble) : ChatAction + + /** Leaves selection mode without acting on anything. */ + data object ClearSelection : ChatAction } typealias ChatActionHandler = (ChatAction) -> Unit diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index 92a71f321d..a5dc8cdb4a 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -1,6 +1,7 @@ package com.flipcash.shared.chat.models import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.MessageCapability import kotlin.time.Instant enum class ReceiptStatus { SENDING, SENT, READ, FAILED } @@ -26,7 +27,29 @@ sealed interface ChatListItem { val isEdited: Boolean = false, /** Chooses between "You deleted this message" and "This message was deleted". */ val deletedByViewer: Boolean = false, + /** + * What the viewer may do to the message this bubble belongs to, resolved once in the + * transcript pipeline by + * [resolveCapabilities][com.flipcash.shared.chat.resolveCapabilities]. + * + * Carried on the bubble so no surface re-derives it: the selection bar asks the set what to + * offer, and a later role taxonomy changes the resolver rather than the menu. + */ + val capabilities: Set = emptySet(), ) : ChatListItem { + /** The body a Copy or an Edit acts on, or `null` for a bubble that carries no text. */ + val plainText: String? + get() = (content as? MessageContent.Text)?.text + + /** + * Whether long-pressing this bubble should open the selection bar. + * + * [MessageCapability.Reply] alone is not enough — replies have no surface yet, so a cash + * bubble would open a bar with nothing in it. + */ + val isSelectable: Boolean + get() = capabilities.any { it != MessageCapability.Reply } + override val itemKey: Any = pendingClientIdHex ?: "$messageId-$contentIndex" // A tombstone shares the text bubble's content type on purpose: deleting a message is an diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 804644eb70..188fcace32 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.text.appendInlineContent -import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Text import androidx.compose.foundation.text.InlineTextContent import androidx.compose.runtime.Composable @@ -215,16 +214,15 @@ private fun TextBubble( emptyMap() } - val bodyText = @Composable { - Text( - text = laidOut, - inlineContent = inlineContent, - style = bodyStyle, - color = bodyColor, - ) - } - - if (isTombstone) bodyText() else SelectionContainer { bodyText() } + // No SelectionContainer: long-press is the transcript's selection gesture, and a text + // selection handle inside the bubble would consume it before the row ever sees it. Copying + // a message is the selection bar's Copy action instead — the same trade WhatsApp makes. + Text( + text = laidOut, + inlineContent = inlineContent, + style = bodyStyle, + color = bodyColor, + ) if (isEdited) { Text( diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemSelectionTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemSelectionTest.kt new file mode 100644 index 0000000000..5a7cfbb62c --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemSelectionTest.kt @@ -0,0 +1,74 @@ +package com.flipcash.shared.chat.models + +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.MessageCapability +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The transcript resolves capabilities once and the bubble carries them; the selection gesture and + * the bar's actions read that set rather than re-deriving anything from the content. + */ +class ChatListItemSelectionTest { + + private val sentAt = Instant.fromEpochSeconds(1_000) + + private fun bubble( + content: MessageContent, + capabilities: Set, + ) = ChatListItem.ContentBubble( + messageId = 42, + contentIndex = 0, + content = content, + isFromSelf = true, + timestamp = sentAt, + capabilities = capabilities, + ) + + @Test + fun `text exposes its body for copy and edit`() { + val text = bubble(MessageContent.Text("hello"), setOf(MessageCapability.Copy)) + assertEquals("hello", text.plainText) + } + + @Test + fun `a bubble that carries no text has no body to act on`() { + val tombstone = bubble(MessageContent.Deleted(sentAt, deletedBy = null), emptySet()) + assertNull(tombstone.plainText) + } + + @Test + fun `reply alone does not make a bubble selectable`() { + // Cash resolves to Reply only, and replies have no surface yet, so a long-press here would + // open a bar with nothing in it. + val cash = bubble(MessageContent.Text("hello"), setOf(MessageCapability.Reply)) + assertFalse(cash.isSelectable) + } + + @Test + fun `any actionable capability makes a bubble selectable`() { + val copyOnly = bubble(MessageContent.Text("hello"), setOf(MessageCapability.Copy)) + val ownMessage = bubble( + MessageContent.Text("hello"), + setOf( + MessageCapability.Copy, + MessageCapability.Reply, + MessageCapability.Edit, + MessageCapability.Delete, + ), + ) + + assertTrue(copyOnly.isSelectable) + assertTrue(ownMessage.isSelectable) + } + + @Test + fun `a message with nothing available is not selectable`() { + val tombstone = bubble(MessageContent.Deleted(sentAt, deletedBy = null), emptySet()) + assertFalse(tombstone.isSelectable) + } +} diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt index f53b1b02c9..c1cde4d26f 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt @@ -1,8 +1,14 @@ package com.getcode.ui.components.chat +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -15,6 +21,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -46,6 +54,9 @@ fun ChatInput( hint: String = "", state: TextFieldState = rememberTextFieldState(), focusRequester: FocusRequester = remember { FocusRequester() }, + // Editing an existing message reuses this composer rather than opening one of its own, so the + // submit affordance has to say which of the two it is about to do. + isEditing: Boolean = false, onSendMessage: () -> Unit, ) { val shape = CodeTheme.shapes.medium @@ -97,9 +108,8 @@ fun ChatInput( unfocusedBorderColor = Color.Transparent, ), trailingIcon = { - Icon( + Box( modifier = Modifier - .testTag("chat_send_icon") .graphicsLayer { alpha = sendAlpha scaleX = sendScale @@ -113,12 +123,40 @@ fun ChatInput( ) .clip(CodeTheme.shapes.extraSmall) .clickable(enabled = sendVisible) { onSendMessage() } - .padding(CodeTheme.dimens.staticGrid.x1) - .size(CodeTheme.dimens.staticGrid.x5), - painter = painterResource(R.drawable.ic_arrow_up), - tint = Color.Black, - contentDescription = "Send message" - ) + .padding(CodeTheme.dimens.staticGrid.x1), + ) { + // The button stays put and only its glyph changes, so entering and leaving edit + // mode reads as the same control changing meaning rather than two controls + // swapping places. + AnimatedContent( + targetState = isEditing, + transitionSpec = { + (fadeIn(sendSpec) + scaleIn(sendSpec, initialScale = 0.6f)) togetherWith + (fadeOut(sendSpec) + scaleOut(sendSpec, targetScale = 0.6f)) + }, + label = "send glyph", + ) { editing -> + if (editing) { + Icon( + modifier = Modifier + .testTag("chat_confirm_edit_icon") + .size(CodeTheme.dimens.staticGrid.x5), + imageVector = Icons.Rounded.Check, + tint = Color.Black, + contentDescription = "Confirm edit", + ) + } else { + Icon( + modifier = Modifier + .testTag("chat_send_icon") + .size(CodeTheme.dimens.staticGrid.x5), + painter = painterResource(R.drawable.ic_arrow_up), + tint = Color.Black, + contentDescription = "Send message", + ) + } + } + } } ) } @@ -151,5 +189,20 @@ private fun Preview_ChatInput_Typing() { } } +@Preview +@Composable +private fun Preview_ChatInput_Editing() { + DesignSystem { + Box(modifier = Modifier.background(Color(0xFF19191A))) { + ChatInput( + modifier = Modifier.padding(15.dp), + onSendMessage = {}, + isEditing = true, + state = TextFieldState("That’s very kind of you. I have") + ) + } + } +} + private val ChatInputButtonSize @Composable get() = CodeTheme.dimens.staticGrid.x7 \ No newline at end of file From f7194652d64e7c0bcd019f1a4897c2ac5f571845 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 15:42:02 -0400 Subject: [PATCH 02/10] fix(chat): keep the message-action bar and transcript coherent Follow-ups from the first pass on a device: - The selection count only ever read "1", and the back arrow already says the bar is a selection, so drop it. - The row highlight fought the bubble's own background. Blurring and dimming the rest of the transcript instead reads better and matches the backdrop iOS holds from the context menu through the edit that can follow it. - The pressed row dips and the selected one springs up, so a long-press answers the finger rather than only changing the bar. - BottomBarManager renders below the IME and a long-press leaves the IME up, so the delete confirmation opened invisible. Hide the keyboard first. - Round the overflow menu and drop it clear of the button it hangs from. - Editing holds the bar in its takeover state with a bare back arrow: falling back to the title bar mid-edit offered the profile and left the system back gesture as the only way out. --- .../internal/screens/ChatAnimations.kt | 4 + .../internal/screens/components/ChatTopBar.kt | 77 ++++++++++++++---- .../screens/components/MessageList.kt | 81 ++++++++++++------- 3 files changed, 119 insertions(+), 43 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ChatAnimations.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ChatAnimations.kt index 3824696653..5cb208fbe7 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ChatAnimations.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ChatAnimations.kt @@ -27,6 +27,10 @@ internal object ChatAnimations { // Delivered -> Read label swap — scale + opacity. val readSwap: SpringSpec = spring(dampingRatio = 0.74f, stiffness = Spring.StiffnessHigh) + // Long-press lift — the row dips under the finger, then springs up while it stands selected. + // Matches the scale UIKit's context menu gives its preview on iOS. + val lift: SpringSpec = spring(dampingRatio = 0.68f, stiffness = 600f) + // Receipt label exit when a new message is sent — fade out + collapse. private val deliveredIntSize: SpringSpec = spring(dampingRatio = 0.88f, stiffness = 250f) val receiptExit: ExitTransition = shrinkVertically(deliveredIntSize) + fadeOut(delivered) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index f5853715a8..624410a59d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.features.messenger.R @@ -39,11 +40,13 @@ import com.flipcash.shared.chat.models.ChatActionHandler import com.flipcash.shared.chat.models.ChatListItem import com.getcode.navigation.core.CodeNavigator import com.getcode.theme.CodeTheme +import com.getcode.theme.extraLarge import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.CircularIconButton import com.getcode.ui.core.measured import com.getcode.ui.core.unboundedClickable +import com.getcode.ui.utils.rememberKeyboardController @Composable internal fun ChatTopBar( @@ -68,24 +71,59 @@ internal fun ChatTopBar( ) ) ) - // Selecting a message takes over the bar rather than stacking a second one over it, so the - // conversation's own actions can't be reached while a message action is pending. + // A message action takes the bar over rather than stacking a second one over it, so the + // conversation's own actions can't be reached while one is pending. The takeover holds + // through the edit that a selection can lead to: dropping back to the title bar mid-edit + // would offer the profile and leave back as the only way out. + val mode: TopBarMode = when { + state.editing != null -> TopBarMode.Editing + state.selection != null -> TopBarMode.Selecting(state.selection) + else -> TopBarMode.Conversation + } AnimatedContent( modifier = Modifier.measured { titleHeight = it.height }, - targetState = state.selection, - contentKey = { it != null }, + targetState = mode, + contentKey = { it::class }, transitionSpec = { fadeIn() togetherWith fadeOut() }, label = "chat top bar", - ) { selection -> - if (selection == null) { - ConversationTitleBar(navigator, state, chatActionHandler) - } else { - MessageSelectionBar(selection, dispatch) + ) { target -> + when (target) { + TopBarMode.Conversation -> ConversationTitleBar(navigator, state, chatActionHandler) + TopBarMode.Editing -> EditingBar(dispatch) + is TopBarMode.Selecting -> MessageSelectionBar(target.selection, dispatch) } } } } +/** What the bar is showing. The payload rides along so a crossfade-out still has it. */ +private sealed interface TopBarMode { + data object Conversation : TopBarMode + data object Editing : TopBarMode + data class Selecting(val selection: ChatListItem.ContentBubble) : TopBarMode +} + +/** Bare back arrow: the composer holds the edit's own cancel and confirm. */ +@Composable +private fun EditingBar(dispatch: (ChatViewModel.Event) -> Unit) { + AppBarWithTitle( + leftIcon = { + CircularIconButton( + onClick = { dispatch(ChatViewModel.Event.CancelEdit) }, + testTag = "action_cancel_edit_from_bar", + ) { size -> + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource(R.string.action_cancelEdit), + tint = Color.White, + modifier = Modifier.requiredSize(size), + ) + } + }, + title = { }, + ) +} + @Composable private fun ConversationTitleBar( navigator: CodeNavigator, @@ -146,6 +184,9 @@ private fun MessageSelectionBar( ) { val capabilities = selection.capabilities val body = selection.plainText + // The long-press leaves the IME up, and the confirmation is a bottom sheet, so it would open + // underneath the keyboard. + val keyboard = rememberKeyboardController() AppBarWithTitle( leftIcon = { @@ -161,15 +202,17 @@ private fun MessageSelectionBar( ) } }, - title = { - // One message at a time today, but the count is what the bar shows and what a later - // multi-selection would change, so it renders as a count rather than a fixed label. - AppBarDefaults.Title(text = "1") - }, + // Nothing in the title slot: one message is selected at a time, so a count would only ever + // read "1" and the back arrow already says the bar is a selection. + title = { }, rightContents = { if (MessageCapability.Delete in capabilities) { CircularIconButton( - onClick = { dispatch(ChatViewModel.Event.DeleteMessage(selection.messageId)) }, + onClick = { + keyboard.hideIfVisible { + dispatch(ChatViewModel.Event.DeleteMessage(selection.messageId)) + } + }, testTag = "action_delete_message", ) { size -> Icon( @@ -208,6 +251,10 @@ private fun MessageOverflow( DropdownMenu( expanded = expanded, containerColor = CodeTheme.colors.brandLight, + // Rounded to the sheet corner and dropped clear of the button, so the menu reads as its + // own surface rather than an extension of the circular icon it hangs from. + shape = CodeTheme.shapes.extraLarge, + offset = DpOffset(x = 0.dp, y = CodeTheme.dimens.grid.x2), onDismissRequest = { expanded = false }, ) { if (canCopy) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 98dd9bfa89..d18e27413a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -2,13 +2,14 @@ package com.flipcash.app.messenger.internal.screens.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterTransition -import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import com.flipcash.app.messenger.internal.screens.ChatAnimations import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,14 +32,13 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemKey @@ -190,37 +190,62 @@ internal fun MessageList( } val bubble = item as? ChatListItem.ContentBubble - val isSelected = bubble != null && state.selection?.itemKey == bubble.itemKey - val bandColor by animateColorAsState( - targetValue = if (isSelected) { - CodeTheme.colors.chat.outgoingBubble.background.copy(alpha = 0.35f) - } else { - Color.Transparent + val interactionSource = remember { MutableInteractionSource() } + + // Selecting or editing a message pushes the rest of the transcript behind a blur, + // leaving the one message sharp — the same treatment iOS holds from its context + // menu through the edit that can follow it. + val focused = when { + state.editing != null -> bubble?.messageId == state.editing.messageId + state.selection != null -> bubble?.itemKey == state.selection.itemKey + else -> true + } + val dimAlpha by animateFloatAsState( + targetValue = if (focused) 1f else 0.4f, + label = "messageDim", + ) + val dimBlur by animateDpAsState( + targetValue = if (focused) 0.dp else 8.dp, + label = "messageBlur", + ) + + // The row answers the finger before the long-press resolves: it dips while held, + // then springs up and stays lifted for as long as it is the selected message. + val pressed by interactionSource.collectIsPressedAsState() + val selecting = state.selection != null || state.editing != null + val lift by animateFloatAsState( + targetValue = when { + selecting && focused -> 1.04f + pressed && bubble?.isSelectable == true -> 0.97f + else -> 1f }, - label = "selectionBand", + animationSpec = ChatAnimations.lift, + label = "messageLift", ) - val bandBleed = CodeTheme.dimens.inset - val interactionSource = remember { MutableInteractionSource() } Box( modifier = Modifier .padding(bottom = bottomSpacing) + // Unbounded: the rectangle treatment would clip the blur at the row's own + // edges and leave a hard seam between neighbouring rows. + .blur(dimBlur, BlurredEdgeTreatment.Unbounded) + .graphicsLayer { + alpha = dimAlpha + scaleX = lift + scaleY = lift + // Anchored to the bubble's own edge, as the insertion animation is, so + // the lift grows the bubble in place instead of sliding it inward. + transformOrigin = if (isOutgoing) { + TransformOrigin(1f, 0.5f) + } else { + TransformOrigin(0f, 0.5f) + } + } .then( if (bubble == null) Modifier else Modifier - .drawBehind { - if (bandColor == Color.Transparent) return@drawBehind - // The list insets its content, so the band is drawn back out - // into that inset to reach both edges of the screen. - val bleed = bandBleed.toPx() - drawRect( - color = bandColor, - topLeft = Offset(-bleed, 0f), - size = Size(size.width + bleed * 2, size.height), - ) - } - // Long-press is the whole row's gesture, not the bubble's: the - // selection band covers the row, and a target that small is worse - // to hit than the one the user already sees highlighted. + // Long-press is the whole row's gesture, not the bubble's: a + // bubble-sized target is harder to hit, and the top bar is what + // reports the selection, so nothing about the row has to change. .combinedClickable( interactionSource = interactionSource, indication = null, From 4952fc98a06ffcdc0cde422604a38a4cc2c4cbe6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 15:45:14 -0400 Subject: [PATCH 03/10] fix(chat): dismiss the keyboard before the delete confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KeyboardController.visible only starts tracking from the composition that creates it. The selection bar is composed by a long-press that leaves the IME up, so its own controller read the keyboard as hidden and hideIfVisible skipped the hide — putting the confirmation sheet behind the keyboard, which is where BottomBarManager renders. Hold the controller in ChatTopBar, which is composed before the IME ever comes up. --- .../internal/screens/components/ChatTopBar.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index 624410a59d..0c096dfddd 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -46,6 +46,7 @@ import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.CircularIconButton import com.getcode.ui.core.measured import com.getcode.ui.core.unboundedClickable +import com.getcode.ui.utils.KeyboardController import com.getcode.ui.utils.rememberKeyboardController @Composable @@ -57,6 +58,10 @@ internal fun ChatTopBar( ) { var titleHeight by remember { mutableStateOf(0.dp) } val bgColor = CodeTheme.colors.background + // Held here rather than in the selection bar: KeyboardController.visible only starts tracking + // from the composition it is created in, and the bar is composed after a long-press that leaves + // the IME already up — a controller created there would read it as hidden. + val keyboard = rememberKeyboardController() Box { Box( modifier = Modifier @@ -90,7 +95,7 @@ internal fun ChatTopBar( when (target) { TopBarMode.Conversation -> ConversationTitleBar(navigator, state, chatActionHandler) TopBarMode.Editing -> EditingBar(dispatch) - is TopBarMode.Selecting -> MessageSelectionBar(target.selection, dispatch) + is TopBarMode.Selecting -> MessageSelectionBar(target.selection, keyboard, dispatch) } } } @@ -180,13 +185,11 @@ private fun ConversationTitleBar( @Composable private fun MessageSelectionBar( selection: ChatListItem.ContentBubble, + keyboard: KeyboardController, dispatch: (ChatViewModel.Event) -> Unit, ) { val capabilities = selection.capabilities val body = selection.plainText - // The long-press leaves the IME up, and the confirmation is a bottom sheet, so it would open - // underneath the keyboard. - val keyboard = rememberKeyboardController() AppBarWithTitle( leftIcon = { From 89747bad270f1b72209a8218c5b20971dce2aff1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 15:56:00 -0400 Subject: [PATCH 04/10] fix(chat): make a tap behind the edit backdrop leave the edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blurred transcript still took row gestures while an edit was open, so a long-press behind the backdrop swapped the selection out from under the message the composer was editing, and a plain tap only dismissed the keyboard. Rows accept no gestures while `state.editing` is set, and a tap anywhere on the transcript cancels the edit instead — the exit iOS gives its held blur. --- .../internal/screens/MessengerScreen.kt | 4 ++++ .../internal/screens/components/MessageList.kt | 18 +++++++++++++++--- .../flipcash/shared/chat/models/ChatAction.kt | 3 +++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 73a34f6623..384c4add5e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -72,6 +72,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { viewModel.dispatchEvent(ChatViewModel.Event.ClearMessageSelection) } + ChatAction.CancelEdit -> { + viewModel.dispatchEvent(ChatViewModel.Event.CancelEdit) + } + is ChatAction.ViewProfile -> { // The triggers (top-bar tap, contact-card chevron) are only clickable for tip DMs // (see State.canViewProfile), so no gating is needed here. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index d18e27413a..0ba34242b7 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -139,8 +139,17 @@ internal fun MessageList( // it, so the sheet snaps back instead of dismissing. Dismiss here comes from the // header/handle drag, scrim tap, and back — all gated by that same allowDismiss flag. modifier = modifier - .pointerInput(Unit) { - detectTapGestures { keyboard.hide() } + // While an edit is open the transcript is behind the backdrop, so a tap on it + // leaves the edit rather than reaching the message it landed on — the same exit + // iOS gives its held blur. + .pointerInput(state.editing != null) { + detectTapGestures { + if (state.editing != null) { + onAction(ChatAction.CancelEdit) + } else { + keyboard.hide() + } + } }, state = listState, reverseLayout = true, @@ -242,7 +251,10 @@ internal fun MessageList( } } .then( - if (bubble == null) Modifier else Modifier + // No row gestures while an edit is open: the rows sit behind the + // backdrop, and a long-press there would swap the selection out from + // under the message the composer is editing. + if (bubble == null || state.editing != null) Modifier else Modifier // Long-press is the whole row's gesture, not the bubble's: a // bubble-sized target is harder to hit, and the top bar is what // reports the selection, so nothing about the row has to change. diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index 0af65a2a75..3aa23686c6 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -21,6 +21,9 @@ sealed interface ChatAction { /** Leaves selection mode without acting on anything. */ data object ClearSelection : ChatAction + + /** Abandons an edit in progress, as a tap on the backdrop behind the edited message does. */ + data object CancelEdit : ChatAction } typealias ChatActionHandler = (ChatAction) -> Unit From 24d9969aab3bbc87bf29c02b481ce6e30d9557cb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 16:07:34 -0400 Subject: [PATCH 05/10] fix(chat): dismiss the backdrop on a tap rather than move the selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The selection backdrop looked modal but wasn't: rows behind it still took taps, so a tap moved the selection to whichever message it landed on, and Edit then acted on that one instead of the message the bar was raised for. The rows take no gestures for as long as the backdrop is up, and a tap on the transcript dismisses what is up — the edit if one is open, otherwise the selection. The row's own tap is now only reachable with the backdrop down, so it does nothing but dismiss the keyboard. --- .../screens/components/MessageList.kt | 37 ++++++++----------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 0ba34242b7..c665fe6ea9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -139,15 +139,15 @@ internal fun MessageList( // it, so the sheet snaps back instead of dismissing. Dismiss here comes from the // header/handle drag, scrim tap, and back — all gated by that same allowDismiss flag. modifier = modifier - // While an edit is open the transcript is behind the backdrop, so a tap on it - // leaves the edit rather than reaching the message it landed on — the same exit - // iOS gives its held blur. - .pointerInput(state.editing != null) { + // The backdrop is modal. While a message is selected or being edited the rest of + // the transcript sits behind it, so a tap there dismisses what is up rather than + // reaching the message it landed on — the exit iOS gives its held blur. + .pointerInput(state.selection != null, state.editing != null) { detectTapGestures { - if (state.editing != null) { - onAction(ChatAction.CancelEdit) - } else { - keyboard.hide() + when { + state.editing != null -> onAction(ChatAction.CancelEdit) + state.selection != null -> onAction(ChatAction.ClearSelection) + else -> keyboard.hide() } } }, @@ -251,10 +251,10 @@ internal fun MessageList( } } .then( - // No row gestures while an edit is open: the rows sit behind the - // backdrop, and a long-press there would swap the selection out from - // under the message the composer is editing. - if (bubble == null || state.editing != null) Modifier else Modifier + // No row gestures while the backdrop is up: the rows are behind it, + // and a press there would move the selection out from under the message + // the bar — or the composer — is already acting on. + if (bubble == null || selecting) Modifier else Modifier // Long-press is the whole row's gesture, not the bubble's: a // bubble-sized target is harder to hit, and the top bar is what // reports the selection, so nothing about the row has to change. @@ -267,16 +267,9 @@ internal fun MessageList( onAction(ChatAction.ToggleSelection(bubble)) } } else null, - onClick = { - when { - // In selection mode a tap continues the selection - // rather than dismissing the keyboard. - state.selection == null -> keyboard.hide() - bubble.isSelectable -> - onAction(ChatAction.ToggleSelection(bubble)) - else -> onAction(ChatAction.ClearSelection) - } - }, + // Only reachable with the backdrop down, so the tap has + // nothing to dismiss but the keyboard. + onClick = { keyboard.hide() }, ) ), ) { From 59b93c2635422c5de0277874c64d17a545b5e92d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 16:48:23 -0400 Subject: [PATCH 06/10] fix(chat): end the selection when the delete confirmation closes Cancelling the sheet left the message alone behind the backdrop under a selection bar the user had just backed out of, which reads as a second confirmation still pending. The sheet's close now ends the selection either way, so cancelling returns the transcript to rest. Also title-cases the confirm button to "Delete For Everyone". --- apps/flipcash/core/src/main/res/values/strings.xml | 2 +- .../com/flipcash/app/messenger/internal/ChatViewModel.kt | 7 ++++--- .../app/messenger/internal/ChatMessageActionReducerTest.kt | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 8b3c692e48..c8b3280431 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -906,7 +906,7 @@ Delete More message actions Clear selection - Delete for everyone + Delete For Everyone Cancel edit Confirm edit Delete message? 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 6fd431ef29..591ca8df0c 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 @@ -743,9 +743,6 @@ internal class ChatViewModel @Inject constructor( BottomBarAction( text = resources.getString(R.string.action_deleteForEveryone), ) { - // The selection bar stays up behind the sheet, so cancelling returns - // to it with the message still selected; only deleting ends selection. - dispatchEvent(Event.ClearMessageSelection) viewModelScope.launch { chatCoordinator.deleteMessage(chatId, event.messageId) .onFailure { cause -> @@ -759,6 +756,10 @@ internal class ChatViewModel @Inject constructor( }, ), showCancel = true, + // Closing the sheet ends the selection either way. Cancelling would otherwise + // leave the message alone behind the backdrop with a bar the user just backed + // out of, which reads as a second confirmation still pending. + onDismiss = { dispatchEvent(Event.ClearMessageSelection) }, ) } .launchIn(viewModelScope) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt index 3db8af379f..e18d30f5bb 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt @@ -95,9 +95,9 @@ class ChatMessageActionReducerTest { } @Test - fun `the selection survives the delete confirmation`() { - // Cancelling the sheet has to land back on the bar it was opened from, so only the - // confirmed delete clears the selection. + fun `the selection is held while the delete confirmation is up`() { + // The message stays focused behind the sheet, so raising the confirmation changes nothing. + // Clearing it is the sheet's close — confirmed or cancelled — which the handler drives. val target = bubble(1) val selected = reduce( ChatViewModel.State(), From c24ab494292c555d904a74fb37d980651a374c9d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 16:57:40 -0400 Subject: [PATCH 07/10] refactor(chat): give ChatInput a submit type instead of an isEditing flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer took `isEditing` and `onSendMessage` as separate parameters, so the call site read `state.editing != null` twice — once to pick the glyph, once inside the lambda to pick between SubmitEdit and SendMessage. Nothing tied the two reads together, so a checkmark that sends a new message was a one-line mistake away. `ChatInputSubmit` carries the action with the kind, so the caller reads its edit state once and the glyph cannot disagree with what the tap does. The click reads the current submit rather than the one the crossfade is showing, so a tap mid-transition does what the composer is now for. An overload pair would have cost the animation: two composables behind an if/else occupy different composition slots, so entering an edit would tear down the text field and swap in a second one, losing focus and replacing the glyph crossfade with a hard cut. --- .../screens/components/ChatBottomBar.kt | 15 ++++--- .../getcode/ui/components/chat/ChatInput.kt | 43 +++++++++++++------ 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index ffd37a2e82..d9647319da 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -49,6 +49,7 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.features.messenger.R import com.getcode.theme.CodeTheme import com.getcode.ui.components.chat.ChatInput +import com.getcode.ui.components.chat.ChatInputSubmit import com.getcode.ui.components.chat.TypingIndicator import com.getcode.ui.core.drawWithGradient import com.getcode.ui.core.measured @@ -197,14 +198,18 @@ internal fun UserControlBottomBar( focusRequester = focusRequester, hint = "Message", state = state.chatInputState, - isEditing = state.editing != null, - onSendMessage = { - if (state.editing != null) { + // One read of the edit state decides both the glyph and what the tap + // does, so the composer cannot show a checkmark and send a new message. + submit = if (state.editing != null) { + ChatInputSubmit.ConfirmEdit { dispatch(ChatViewModel.Event.SubmitEdit) - } else { + keyboard.restartInput() + } + } else { + ChatInputSubmit.Send { dispatch(ChatViewModel.Event.SendMessage) + keyboard.restartInput() } - keyboard.restartInput() }, ) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt index c1cde4d26f..7a086384a2 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt @@ -47,6 +47,24 @@ import com.getcode.theme.inputColors import com.getcode.ui.components.R import com.getcode.ui.components.TextInput +/** + * What the composer will do with the text it holds, and the action that does it. + * + * Editing an existing message reuses this composer rather than opening one of its own, so the submit + * control has to say which of the two it is about to do. Pairing the glyph with the action here + * means a caller cannot put up a checkmark that sends a new message, and leaves it reading its own + * edit state once rather than once per parameter. + */ +sealed interface ChatInputSubmit { + val perform: () -> Unit + + /** Sends the field's text as a new message. */ + data class Send(override val perform: () -> Unit) : ChatInputSubmit + + /** Confirms an edit of a message already in the transcript. */ + data class ConfirmEdit(override val perform: () -> Unit) : ChatInputSubmit +} + @Composable fun ChatInput( modifier: Modifier = Modifier, @@ -54,10 +72,7 @@ fun ChatInput( hint: String = "", state: TextFieldState = rememberTextFieldState(), focusRequester: FocusRequester = remember { FocusRequester() }, - // Editing an existing message reuses this composer rather than opening one of its own, so the - // submit affordance has to say which of the two it is about to do. - isEditing: Boolean = false, - onSendMessage: () -> Unit, + submit: ChatInputSubmit, ) { val shape = CodeTheme.shapes.medium val sendVisible = state.text.isNotEmpty() @@ -122,21 +137,26 @@ fun ChatInput( shape = CodeTheme.shapes.extraSmall ) .clip(CodeTheme.shapes.extraSmall) - .clickable(enabled = sendVisible) { onSendMessage() } + // Reads the current submit rather than the one the crossfade happens to + // be showing, so a tap mid-transition does what the composer is now for. + .clickable(enabled = sendVisible) { submit.perform() } .padding(CodeTheme.dimens.staticGrid.x1), ) { // The button stays put and only its glyph changes, so entering and leaving edit // mode reads as the same control changing meaning rather than two controls // swapping places. AnimatedContent( - targetState = isEditing, + targetState = submit, + // Keyed by kind, not by value: each submit carries a fresh lambda, so + // equality alone would restart the crossfade on every recomposition. + contentKey = { it::class }, transitionSpec = { (fadeIn(sendSpec) + scaleIn(sendSpec, initialScale = 0.6f)) togetherWith (fadeOut(sendSpec) + scaleOut(sendSpec, targetScale = 0.6f)) }, label = "send glyph", - ) { editing -> - if (editing) { + ) { target -> + if (target is ChatInputSubmit.ConfirmEdit) { Icon( modifier = Modifier .testTag("chat_confirm_edit_icon") @@ -169,7 +189,7 @@ private fun Preview_ChatInput_Empty() { Box(modifier = Modifier.background(Color(0xFF19191A))) { ChatInput( modifier = Modifier.padding(15.dp), - onSendMessage = {}, + submit = ChatInputSubmit.Send {}, ) } } @@ -182,7 +202,7 @@ private fun Preview_ChatInput_Typing() { Box(modifier = Modifier.background(Color(0xFF19191A))) { ChatInput( modifier = Modifier.padding(15.dp), - onSendMessage = {}, + submit = ChatInputSubmit.Send {}, state = TextFieldState("That’s very kind of you. I ha") ) } @@ -196,8 +216,7 @@ private fun Preview_ChatInput_Editing() { Box(modifier = Modifier.background(Color(0xFF19191A))) { ChatInput( modifier = Modifier.padding(15.dp), - onSendMessage = {}, - isEditing = true, + submit = ChatInputSubmit.ConfirmEdit {}, state = TextFieldState("That’s very kind of you. I have") ) } From e2f0ed873a42ef0aebd19fb211fe11d43b7595ab Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 17:02:55 -0400 Subject: [PATCH 08/10] fix(chat): drop the message focus while the delete confirmation is up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The confirmation sheet rises over the transcript, and the selected message stayed sharp and lifted behind it — half of it clipped by the sheet's top edge, the rest reading as still live while a modal asked about it. `confirmingDelete` puts every row behind the backdrop for as long as the sheet is up. The selection itself is untouched, so Cancel still has a bar to return to; only the focus goes. The flag clears wherever the selection does, so the sheet's close — confirmed or cancelled — brings the transcript back to rest. Also drops the trailing period from the sheet's body. --- .../core/src/main/res/values/strings.xml | 2 +- .../app/messenger/internal/ChatViewModel.kt | 27 +++++++++++++++---- .../screens/components/MessageList.kt | 4 +++ .../internal/ChatMessageActionReducerTest.kt | 24 ++++++++++++++--- 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index c8b3280431..92f37fa942 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -910,7 +910,7 @@ Cancel edit Confirm edit Delete message? - This can\'t be undone. + This can\'t be undone Message Not Edited Your change couldn\'t be saved, so the message is unchanged. Message Not Deleted 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 591ca8df0c..499f6aeaa4 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 @@ -162,6 +162,14 @@ internal class ChatViewModel @Inject constructor( val selection: ChatListItem.ContentBubble? = null, /** The message the composer is editing, or `null` when it is composing a new one. */ val editing: EditingMessage? = null, + /** + * True while the delete confirmation is up. + * + * The sheet is modal, so nothing behind it should still read as the focus: the selected + * message falls back behind the backdrop with the rest of the transcript until the sheet + * closes, rather than sitting sharp and half-clipped at the sheet's own edge. + */ + val confirmingDelete: Boolean = false, ) { // Opening the participant's profile (the entry point to blocking) is only available for tip DMs. val canViewProfile: Boolean @@ -1162,13 +1170,21 @@ internal class ChatViewModel @Inject constructor( is Event.ChatDeactivated -> { state -> state.copy(isAnonymous = event.isReadOnly) } is Event.ToggleMessageSelection -> { state -> val alreadySelected = state.selection?.itemKey == event.bubble.itemKey - state.copy(selection = event.bubble.takeUnless { alreadySelected }) + state.copy( + selection = event.bubble.takeUnless { alreadySelected }, + confirmingDelete = false, + ) + } + Event.ClearMessageSelection -> { state -> + state.copy(selection = null, confirmingDelete = false) + } + is Event.CopyMessage -> { state -> + state.copy(selection = null, confirmingDelete = false) } - Event.ClearMessageSelection -> { state -> state.copy(selection = null) } - is Event.CopyMessage -> { state -> state.copy(selection = null) } is Event.EditMessage -> { state -> state.copy( selection = null, + confirmingDelete = false, editing = EditingMessage( messageId = event.messageId, originalText = event.text, @@ -1179,8 +1195,9 @@ internal class ChatViewModel @Inject constructor( ), ) } - // Selection survives the confirmation sheet; the delete action clears it. - is Event.DeleteMessage -> { state -> state } + // Selection survives the confirmation sheet, but the focus does not: the sheet is + // modal, so the transcript behind it goes uniformly dim until the sheet closes. + is Event.DeleteMessage -> { state -> state.copy(confirmingDelete = true) } Event.SubmitEdit -> { state -> state } Event.CancelEdit -> { state -> state } Event.EditingEnded -> { state -> state.copy(editing = null) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index c665fe6ea9..ac39f6d6cb 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -205,6 +205,10 @@ internal fun MessageList( // leaving the one message sharp — the same treatment iOS holds from its context // menu through the edit that can follow it. val focused = when { + // The delete confirmation is modal, so nothing behind it is the focus: the + // selected message drops back with the rest rather than sitting sharp and + // half-clipped where the sheet cuts across it. + state.confirmingDelete -> false state.editing != null -> bubble?.messageId == state.editing.messageId state.selection != null -> bubble?.itemKey == state.selection.itemKey else -> true diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt index e18d30f5bb..4a22f139b8 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt @@ -6,9 +6,11 @@ import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.models.ChatListItem import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertSame +import kotlin.test.assertTrue import kotlin.time.Instant /** @@ -95,9 +97,9 @@ class ChatMessageActionReducerTest { } @Test - fun `the selection is held while the delete confirmation is up`() { - // The message stays focused behind the sheet, so raising the confirmation changes nothing. - // Clearing it is the sheet's close — confirmed or cancelled — which the handler drives. + fun `the delete confirmation holds the selection but drops the focus`() { + // The bar still has its message, so the sheet's Cancel has something to return to. The + // focus goes because the sheet is modal — a sharp bubble behind it reads as still live. val target = bubble(1) val selected = reduce( ChatViewModel.State(), @@ -107,6 +109,22 @@ class ChatMessageActionReducerTest { val state = reduce(selected, ChatViewModel.Event.DeleteMessage(target.messageId)) assertSame(target, state.selection) + assertTrue(state.confirmingDelete) + } + + @Test + fun `closing the delete confirmation returns the transcript to rest`() { + // Confirmed or cancelled, the sheet's close is ClearMessageSelection, which the handler + // drives. Leaving confirmingDelete set would hold the whole transcript behind the backdrop. + val confirming = reduce( + reduce(ChatViewModel.State(), ChatViewModel.Event.ToggleMessageSelection(bubble(1))), + ChatViewModel.Event.DeleteMessage(1), + ) + + val state = reduce(confirming, ChatViewModel.Event.ClearMessageSelection) + + assertNull(state.selection) + assertFalse(state.confirmingDelete) } @Test From 6903e4f35b5296effe890ab5b81a3a5f1ed7b06e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 17:21:49 -0400 Subject: [PATCH 09/10] feat(chat): render the selection bar's actions inline where they fit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bar named its own split: delete inline, copy and edit under the overflow. That was one layout applied to every screen, and on a 411dp phone all three actions fit with room to spare, so two of them sat behind a menu for no reason. The actions now get a share of the bar — 35% — and take icons in priority order for as far as that reaches, with the remainder collapsing into the overflow. A 411dp phone shows delete, copy and edit; a 320dp one falls back to delete inline plus an overflow holding the other two, which is what the bar did everywhere before. The overflow only claims a slot when it has something to hold, so the last action is never displaced by a menu containing only it. Both the icons and the menu items now come off one list, so a label and the action it fires cannot drift apart, and reply takes its place in that order when it lands rather than arriving with a layout decision attached. --- .../internal/screens/components/ChatTopBar.kt | 151 +++++++++++++----- 1 file changed, 107 insertions(+), 44 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index 0c096dfddd..0bfe253e43 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -7,6 +7,7 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -14,7 +15,9 @@ import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon @@ -29,6 +32,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp @@ -179,8 +183,10 @@ private fun ConversationTitleBar( /** * The bar a long-press puts up, offering exactly what the transcript resolved for that message. * - * Delete sits inline and the rest go under the overflow, following WhatsApp: the destructive action - * is the one worth a dedicated target, and burying it would make the common case a two-tap one. + * The actions render as icons for as far as the bar's action budget reaches, and whatever is left + * over goes under an overflow. Which ones that is falls out of the width rather than being named + * here, so the same set sits flat on a phone and collapses on a narrow one — and an action added + * later takes its place in the order without a layout decision attached. */ @Composable private fun MessageSelectionBar( @@ -191,6 +197,49 @@ private fun MessageSelectionBar( val capabilities = selection.capabilities val body = selection.plainText + // Order is priority: the first actions keep their icons when the bar runs out of room. Delete + // leads because burying the one action with a confirmation behind a menu makes it a three-tap + // job, and it is the action WhatsApp keeps inline too. + val actions = buildList { + if (MessageCapability.Delete in capabilities) { + add( + MessageAction( + label = stringResource(R.string.action_delete), + icon = Icons.Outlined.Delete, + testTag = "action_delete_message", + onClick = { + keyboard.hideIfVisible { + dispatch(ChatViewModel.Event.DeleteMessage(selection.messageId)) + } + }, + ) + ) + } + // Copy and edit both act on the message's text, so a bubble without any is offered neither. + if (body != null && MessageCapability.Copy in capabilities) { + add( + MessageAction( + label = stringResource(R.string.action_copy), + icon = Icons.Outlined.ContentCopy, + testTag = "action_copy_message", + onClick = { dispatch(ChatViewModel.Event.CopyMessage(body)) }, + ) + ) + } + if (body != null && MessageCapability.Edit in capabilities) { + add( + MessageAction( + label = stringResource(R.string.action_edit), + icon = Icons.Outlined.Edit, + testTag = "action_edit_message", + onClick = { + dispatch(ChatViewModel.Event.EditMessage(selection.messageId, body)) + }, + ) + ) + } + } + AppBarWithTitle( leftIcon = { CircularIconButton( @@ -208,45 +257,68 @@ private fun MessageSelectionBar( // Nothing in the title slot: one message is selected at a time, so a count would only ever // read "1" and the back arrow already says the bar is a selection. title = { }, - rightContents = { - if (MessageCapability.Delete in capabilities) { - CircularIconButton( - onClick = { - keyboard.hideIfVisible { - dispatch(ChatViewModel.Event.DeleteMessage(selection.messageId)) - } - }, - testTag = "action_delete_message", - ) { size -> + rightContents = { MessageActions(actions) }, + ) +} + +/** One thing the selection bar can do to the selected message. */ +private data class MessageAction( + val label: String, + val icon: ImageVector, + val testTag: String, + val onClick: () -> Unit, +) + +/** + * The share of the bar the actions may occupy before they start collapsing into the overflow. + * + * A share rather than a slot count, so the answer tracks the screen: at 40dp a button and 10dp + * between them, a normal phone fits all three actions and a compact one keeps the first inline + * with the rest a tap away. It stays a minority of the bar so the cluster still reads as trailing + * and leaves room for a title, should the selection bar ever grow one. + */ +private const val ActionBudgetFraction = 0.35f + +@Composable +private fun MessageActions(actions: List) { + if (actions.isEmpty()) return + + // Both match what the app bar itself uses, so the budget is measured in the widths that will + // actually be laid out. + val buttonSize = CodeTheme.dimens.staticGrid.x8 + val spacing = CodeTheme.dimens.grid.x2 + + BoxWithConstraints { + // n buttons cost n widths and n-1 gaps, so adding one gap to both sides makes it a division. + val capacity = ((maxWidth * ActionBudgetFraction + spacing) / (buttonSize + spacing)) + .toInt() + .coerceAtLeast(1) + // The overflow needs a slot of its own, so it only pays for itself when it is holding + // something — the last action is not displaced by a menu that would contain only it. + val inline = if (actions.size <= capacity) actions else actions.take(capacity - 1) + + Row( + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalAlignment = Alignment.CenterVertically, + ) { + inline.forEach { action -> + CircularIconButton(onClick = action.onClick, testTag = action.testTag) { size -> Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = stringResource(R.string.action_delete), + imageVector = action.icon, + contentDescription = action.label, tint = Color.White, modifier = Modifier.requiredSize(size), ) } } - - if (body != null) { - MessageOverflow( - capabilities = capabilities, - onCopy = { dispatch(ChatViewModel.Event.CopyMessage(body)) }, - onEdit = { dispatch(ChatViewModel.Event.EditMessage(selection.messageId, body)) }, - ) - } - }, - ) + MessageOverflow(actions.drop(inline.size)) + } + } } @Composable -private fun MessageOverflow( - capabilities: Set, - onCopy: () -> Unit, - onEdit: () -> Unit, -) { - val canCopy = MessageCapability.Copy in capabilities - val canEdit = MessageCapability.Edit in capabilities - if (!canCopy && !canEdit) return +private fun MessageOverflow(actions: List) { + if (actions.isEmpty()) return var expanded by remember { mutableStateOf(false) } Box { @@ -260,21 +332,12 @@ private fun MessageOverflow( offset = DpOffset(x = 0.dp, y = CodeTheme.dimens.grid.x2), onDismissRequest = { expanded = false }, ) { - if (canCopy) { - DropdownMenuItem( - text = { OverflowLabel(stringResource(R.string.action_copy)) }, - onClick = { - expanded = false - onCopy() - }, - ) - } - if (canEdit) { + actions.forEach { action -> DropdownMenuItem( - text = { OverflowLabel(stringResource(R.string.action_edit)) }, + text = { OverflowLabel(action.label) }, onClick = { expanded = false - onEdit() + action.onClick() }, ) } From fca272a0636bbe3f3154bc81418a48da7b10febf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 17:38:02 -0400 Subject: [PATCH 10/10] fix(chat): take the cash bubble's tap with the rest of the transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backdrop gated the rows but not the targets inside them, so a cash bubble behind a raised selection bar still opened token info — and so did the contact card at the start of history. Both now read the same flag the rows do, which drops their click rather than consuming the tap, so the press falls through to the backdrop and dismisses like a tap anywhere else on the dimmed transcript. Token info opens as a push from here rather than the fade-in-place expand. That transition is the wallet card growing into its own detail; a transcript has no card for it to grow from, and discovery already drills in the same way. --- .../internal/screens/MessengerScreen.kt | 7 ++- .../screens/components/MessageList.kt | 55 +++++++++++-------- .../flipcash/shared/chat/ui/MessageBubble.kt | 14 +++-- 3 files changed, 47 insertions(+), 29 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 384c4add5e..a0e9dcfa78 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -57,7 +57,12 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { is ChatAction.ViewToken -> { keyboard.hideIfVisible { viewModel.dispatchEvent( - ChatViewModel.Event.OpenScreen(AppRoute.Token.Info(action.mint)) + ChatViewModel.Event.OpenScreen( + // A drill-in from the transcript, so push it: the fade-in-place + // expand is the wallet card growing into its own detail, and there + // is no card here for it to grow from. + AppRoute.Token.Info(action.mint, asPush = true) + ) ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index ac39f6d6cb..24b8d90e24 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -55,6 +55,7 @@ import com.flipcash.shared.chat.models.ReceiptStatus import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.shared.chat.ui.bubblePositionOf import com.getcode.theme.CodeTheme +import com.getcode.ui.core.addIf import com.getcode.ui.utils.rememberKeyboardController import com.getcode.util.vibration.LocalVibrator import kotlinx.coroutines.flow.collectLatest @@ -116,6 +117,10 @@ internal fun MessageList( // across item disposal so scrolling away and back doesn't replay. val animatedKeys = remember { mutableSetOf() } + // The backdrop, read once for everything it covers: the rows, the bubbles' own targets, + // and the contact card at the start of history all stop taking taps together. + val selecting = state.selection != null || state.editing != null + // Track when the initial Paging refresh has truly completed (Loading → NotLoading). // This avoids showing the ContactInfoContainer before messages arrive, // which would cause the list to start scrolled to the wrong position. @@ -225,7 +230,6 @@ internal fun MessageList( // The row answers the finger before the long-press resolves: it dips while held, // then springs up and stays lifted for as long as it is the selected message. val pressed by interactionSource.collectIsPressedAsState() - val selecting = state.selection != null || state.editing != null val lift by animateFloatAsState( targetValue = when { selecting && focused -> 1.04f @@ -254,28 +258,27 @@ internal fun MessageList( TransformOrigin(0f, 0.5f) } } - .then( - // No row gestures while the backdrop is up: the rows are behind it, - // and a press there would move the selection out from under the message - // the bar — or the composer — is already acting on. - if (bubble == null || selecting) Modifier else Modifier - // Long-press is the whole row's gesture, not the bubble's: a - // bubble-sized target is harder to hit, and the top bar is what - // reports the selection, so nothing about the row has to change. - .combinedClickable( - interactionSource = interactionSource, - indication = null, - onLongClick = if (bubble.isSelectable) { - { - vibrator.tick() - onAction(ChatAction.ToggleSelection(bubble)) - } - } else null, - // Only reachable with the backdrop down, so the tap has - // nothing to dismiss but the keyboard. - onClick = { keyboard.hide() }, - ) - ), + // No row gestures while the backdrop is up: the rows are behind it, and a + // press there would move the selection out from under the message the bar — + // or the composer — is already acting on. + .addIf(bubble != null && !selecting) { + // Long-press is the whole row's gesture, not the bubble's: a + // bubble-sized target is harder to hit, and the top bar is what reports + // the selection, so nothing about the row has to change. + Modifier.combinedClickable( + interactionSource = interactionSource, + indication = null, + onLongClick = bubble?.takeIf { it.isSelectable }?.let { target -> + { + vibrator.tick() + onAction(ChatAction.ToggleSelection(target)) + } + }, + // Only reachable with the backdrop down, so the tap has nothing to + // dismiss but the keyboard. + onClick = { keyboard.hide() }, + ) + }, ) { when (item) { is ChatListItem.DateSeparator -> Box(insertionModifier) { @@ -300,6 +303,10 @@ internal fun MessageList( Box(insertionModifier) { ContentBubble( item = item, + // The bubble's own targets go with the row's: a cash + // bubble behind the backdrop would otherwise open token + // info from under the bar. + interactive = !selecting, position = bubblePositionOf( index, item, @@ -373,7 +380,7 @@ internal fun MessageList( onRefreshContact = { onAction(ChatAction.RefreshContact) }, // null hides the chevron and makes the card non-tappable when the // profile isn't viewable (non-tip-DM chats). - onOpenProfile = if (canViewProfile) { + onOpenProfile = if (canViewProfile && !selecting) { { onAction(ChatAction.ViewProfile) } } else { null diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 188fcace32..8eccb3f864 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -76,6 +76,7 @@ fun ContentBubble( item: ChatListItem.ContentBubble, position: BubblePosition, modifier: Modifier = Modifier, + interactive: Boolean = true, ) { val actionHandler = LocalChatActionHandler.current BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { @@ -128,9 +129,14 @@ fun ContentBubble( action = content.action, position = position, maxWidth = bubbleMaxWidth, - onClick = { - actionHandler(ChatAction.ViewToken(content.mint)) - } + // Dropped rather than ignored while the transcript is behind a backdrop: with + // no click installed the tap reaches the backdrop and dismisses it, which is + // what a tap anywhere else on the dimmed transcript already does. + onClick = if (interactive) { + { actionHandler(ChatAction.ViewToken(content.mint)) } + } else { + null + }, ) // TODO @@ -244,7 +250,7 @@ private fun CashBubble( position: BubblePosition, maxWidth: Dp, action: MessageContent.Cash.Action = MessageContent.Cash.Action.SENT, - onClick: () -> Unit = { }, + onClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { Bubble(