diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index 089cdb6569..92f37fa942 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..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
@@ -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,47 @@ 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,
+ /**
+ * 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
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 +235,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 +273,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 +305,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 +317,7 @@ internal class ChatViewModel @Inject constructor(
ChatListItem.DateSeparator(before.timestamp)
} else null
}
- }.cachedIn(viewModelScope)
+ }
private val maxAmountFlow by lazy {
combine(
@@ -364,6 +434,7 @@ internal class ChatViewModel @Inject constructor(
initTokenAndExchangeObservers()
initTypingHandlers()
initSendHandlers()
+ initMessageActionHandlers()
}
}
@@ -623,6 +694,91 @@ 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),
+ ) {
+ 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,
+ // 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)
+ }
+
+ /** 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 +1168,39 @@ 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 },
+ confirmingDelete = false,
+ )
+ }
+ Event.ClearMessageSelection -> { state ->
+ state.copy(selection = null, confirmingDelete = false)
+ }
+ is Event.CopyMessage -> { state ->
+ state.copy(selection = null, confirmingDelete = false)
+ }
+ is Event.EditMessage -> { state ->
+ state.copy(
+ selection = null,
+ confirmingDelete = false,
+ 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, 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/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/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt
index c56dc3e58b..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
@@ -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
@@ -56,11 +57,30 @@ 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)
+ )
)
}
}
+ is ChatAction.ToggleSelection -> {
+ viewModel.dispatchEvent(
+ ChatViewModel.Event.ToggleMessageSelection(action.bubble)
+ )
+ }
+
+ ChatAction.ClearSelection -> {
+ 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.
@@ -75,13 +95,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..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
@@ -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
@@ -40,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
@@ -154,16 +164,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 +198,30 @@ internal fun UserControlBottomBar(
focusRequester = focusRequester,
hint = "Message",
state = state.chatInputState,
- onSendMessage = {
- dispatch(ChatViewModel.Event.SendMessage)
- keyboard.restartInput()
+ // 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)
+ keyboard.restartInput()
+ }
+ } else {
+ ChatInputSubmit.Send {
+ 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 +241,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..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
@@ -1,13 +1,26 @@
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
+import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
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.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
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -19,25 +32,40 @@ 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
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.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.KeyboardController
+import com.getcode.ui.utils.rememberKeyboardController
@Composable
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
+ // 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
@@ -52,45 +80,276 @@ internal fun ChatTopBar(
)
)
)
- AppBarWithTitle(
+ // 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 },
- 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 = mode,
+ contentKey = { it::class },
+ transitionSpec = { fadeIn() togetherWith fadeOut() },
+ label = "chat top bar",
+ ) { target ->
+ when (target) {
+ TopBarMode.Conversation -> ConversationTitleBar(navigator, state, chatActionHandler)
+ TopBarMode.Editing -> EditingBar(dispatch)
+ is TopBarMode.Selecting -> MessageSelectionBar(target.selection, keyboard, 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,
+ 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.
+ *
+ * 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(
+ selection: ChatListItem.ContentBubble,
+ keyboard: KeyboardController,
+ 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,
+ // 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(
+ 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),
+ )
+ }
+ },
+ // 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 = { 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 = action.icon,
+ contentDescription = action.label,
+ tint = Color.White,
+ modifier = Modifier.requiredSize(size),
)
}
}
- )
+ MessageOverflow(actions.drop(inline.size))
+ }
}
-}
\ No newline at end of file
+}
+
+@Composable
+private fun MessageOverflow(actions: List) {
+ if (actions.isEmpty()) return
+
+ var expanded by remember { mutableStateOf(false) }
+ Box {
+ AppBarDefaults.Overflow(onClick = { expanded = true })
+ 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 },
+ ) {
+ actions.forEach { action ->
+ DropdownMenuItem(
+ text = { OverflowLabel(action.label) },
+ onClick = {
+ expanded = false
+ action.onClick()
+ },
+ )
+ }
+ }
+ }
+}
+
+@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..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
@@ -2,9 +2,14 @@ package com.flipcash.app.messenger.internal.screens.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
+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
@@ -27,10 +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.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
@@ -47,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
@@ -56,6 +65,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,
@@ -107,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.
@@ -130,8 +144,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() }
+ // 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 {
+ when {
+ state.editing != null -> onAction(ChatAction.CancelEdit)
+ state.selection != null -> onAction(ChatAction.ClearSelection)
+ else -> keyboard.hide()
+ }
+ }
},
state = listState,
reverseLayout = true,
@@ -180,9 +203,82 @@ internal fun MessageList(
}
}
+ val bubble = item as? ChatListItem.ContentBubble
+ 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 {
+ // 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
+ }
+ 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 lift by animateFloatAsState(
+ targetValue = when {
+ selecting && focused -> 1.04f
+ pressed && bubble?.isSelectable == true -> 0.97f
+ else -> 1f
+ },
+ animationSpec = ChatAnimations.lift,
+ label = "messageLift",
+ )
+
Box(
modifier = Modifier
- .padding(bottom = bottomSpacing),
+ .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)
+ }
+ }
+ // 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) {
@@ -207,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,
@@ -280,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/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..4a22f139b8
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt
@@ -0,0 +1,190 @@
+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.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+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 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(),
+ ChatViewModel.Event.ToggleMessageSelection(target),
+ )
+
+ 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
+ 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..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
@@ -9,6 +9,21 @@ 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
+
+ /** Abandons an edit in progress, as a tap on the backdrop behind the edited message does. */
+ data object CancelEdit : 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..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
@@ -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
@@ -77,6 +76,7 @@ fun ContentBubble(
item: ChatListItem.ContentBubble,
position: BubblePosition,
modifier: Modifier = Modifier,
+ interactive: Boolean = true,
) {
val actionHandler = LocalChatActionHandler.current
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
@@ -129,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
@@ -215,16 +220,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(
@@ -246,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(
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..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
@@ -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
@@ -39,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,
@@ -46,7 +72,7 @@ fun ChatInput(
hint: String = "",
state: TextFieldState = rememberTextFieldState(),
focusRequester: FocusRequester = remember { FocusRequester() },
- onSendMessage: () -> Unit,
+ submit: ChatInputSubmit,
) {
val shape = CodeTheme.shapes.medium
val sendVisible = state.text.isNotEmpty()
@@ -97,9 +123,8 @@ fun ChatInput(
unfocusedBorderColor = Color.Transparent,
),
trailingIcon = {
- Icon(
+ Box(
modifier = Modifier
- .testTag("chat_send_icon")
.graphicsLayer {
alpha = sendAlpha
scaleX = sendScale
@@ -112,13 +137,46 @@ fun ChatInput(
shape = CodeTheme.shapes.extraSmall
)
.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"
- )
+ // 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 = 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",
+ ) { target ->
+ if (target is ChatInputSubmit.ConfirmEdit) {
+ 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",
+ )
+ }
+ }
+ }
}
)
}
@@ -131,7 +189,7 @@ private fun Preview_ChatInput_Empty() {
Box(modifier = Modifier.background(Color(0xFF19191A))) {
ChatInput(
modifier = Modifier.padding(15.dp),
- onSendMessage = {},
+ submit = ChatInputSubmit.Send {},
)
}
}
@@ -144,12 +202,26 @@ 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")
)
}
}
}
+@Preview
+@Composable
+private fun Preview_ChatInput_Editing() {
+ DesignSystem {
+ Box(modifier = Modifier.background(Color(0xFF19191A))) {
+ ChatInput(
+ modifier = Modifier.padding(15.dp),
+ submit = ChatInputSubmit.ConfirmEdit {},
+ 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