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