From b70823f814ca281cd5c962631c5c6d8e985e99ee Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:26:57 -0400 Subject: [PATCH] fix(chat): keep the focused message where it was long-pressed Editing a message part-way up the thread scrolled it off the top. The list is reverseLayout inside an imePadding'd scaffold, so the keyboard shortens the viewport from the bottom and every row rides up by the keyboard's height. FocusPin hands that height back to the list in the layout pass, letting its bottom-anchored content overflow behind the keyboard so the focused row lands back where it started. Reading the compensation in the layout phase is what keeps it in step with imePadding across the IME animation; a counter-scroll from an effect trails it by a frame the whole way down. The keyboard's height is not the whole of the movement. The input bar's navigationBarsPadding subtracts the insets the scaffold's imePadding has already consumed, so the bar loses its navigation-bar inset as the keyboard arrives and the list's bottom padding shrinks with it, dropping the content back down. Correcting for the keyboard alone left the row 63px low on a Pixel 10 emulator, exactly that inset, so the pin corrects for both terms. The total is capped at the room the row had above the composer, so a message already sitting near the bar rides up with the bar instead of being buried. On release it animates back to zero rather than being handed to the scroll position, which would have cost the transcript a keyboard's worth of history once the keyboard closed. The pin holds for the whole focus, not just the edit it can lead to. Tapping the composer with the selection bar up raises the keyboard as well, and a selected message pushed off the top is the same message lost from under the buttons acting on it. Around that: - The transcript stops taking drags for as long as the backdrop is up, selection and edit alike. It is behind the same scrim that already swallows taps. - A message arriving mid-edit still scrolls the list, but only as far as the point where the edited row would pass under the top bar. - A row long-pressed while it runs under the top bar is brought level with the bar's lower edge, so it isn't left sitting behind the buttons acting on it. - Back unwinds the edit, then the selection, before it leaves the conversation. MessageList had grown to the point where the per-row rendering obscured this, so the row, the pin, the read reporting and the receipt rules move to their own files. --- .../internal/screens/MessengerScreen.kt | 3 +- .../internal/screens/components/FocusPin.kt | 214 +++++++++ .../screens/components/MessageList.kt | 407 +++--------------- .../screens/components/MessageReadReporter.kt | 57 +++ .../internal/screens/components/MessageRow.kt | 250 +++++++++++ .../screens/components/ReceiptRules.kt | 93 ++++ 6 files changed, 685 insertions(+), 339 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/FocusPin.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt 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 a0e9dcfa7..a7393fd6e 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 @@ -96,7 +96,8 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } // Back unwinds the message actions before it leaves the conversation, innermost first: an edit - // in progress, then the selection bar. + // in progress, then the selection bar. Registered here rather than around the whole flow so + // they are the innermost handlers and take the gesture before it pops the conversation. BackHandler(enabled = state.editing != null) { viewModel.dispatchEvent(ChatViewModel.Event.CancelEdit) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/FocusPin.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/FocusPin.kt new file mode 100644 index 000000000..bc5b02dac --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/FocusPin.kt @@ -0,0 +1,214 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.constrainHeight +import androidx.paging.compose.LazyPagingItems +import com.flipcash.shared.chat.models.ChatListItem + +/** + * Keeps the message a long-press has put in focus where the user left it while the keyboard is up. + * + * The conversation is inset by `Modifier.imePadding()`, so opening the keyboard shortens the + * transcript's viewport from the bottom. The list is `reverseLayout`, i.e. anchored to the bottom, + * so every row rides up by the keyboard height — far enough that a message part-way up the thread + * leaves the top of the screen the moment the keyboard opens over it. + * + * The pin hands the list back exactly the height the keyboard took, letting it overflow behind the + * keyboard: bottom-anchored content then moves down by the same amount it was pushed up and the + * focused row lands back where it started. [slack] caps that at the room the row had above the + * composer, so a message that was already sitting near the bar rides up with the bar rather than + * being buried under the keyboard. + * + * It holds for the whole focus, not just the edit it can lead to. The keyboard comes up on the edit, + * but it also comes up if the composer is tapped while the selection bar is showing, and a selected + * message pushed off the top is the same message lost from under the buttons acting on it. + * + * The compensation is read in the layout pass ([holdFocusedMessageInPlace]), the same phase + * `imePadding` resolves in, so the two move together across the IME animation. A counter-scroll + * driven from an effect would be a frame behind it the whole way down. + */ +@Stable +internal class FocusPin { + var active by mutableStateOf(false) + private set + + /** Bottom inset at the moment the focus began; only growth beyond this has to be cancelled. */ + private var imeAtStart by mutableIntStateOf(0) + + /** The list's own bottom padding at the moment the focus began; see [compensation]. */ + private var bottomPadAtStart by mutableIntStateOf(0) + + /** + * How far the pinned row can travel down before it meets the composer, in px. Measured once, at + * the start: a message arriving later scrolls the row further up and so only ever leaves this + * an underestimate, which caps the compensation low rather than pushing the row back down over + * the scroll that just moved it. + */ + private var slack by mutableIntStateOf(0) + + /** What the pin is still handing back after a focus ends, on its way to nothing. See [release]. */ + private val unwind = Animatable(0f) + + suspend fun arm(imeBottom: Int, bottomPad: Int, slackPx: Int) { + imeAtStart = imeBottom + bottomPadAtStart = bottomPad + slack = slackPx.coerceAtLeast(0) + active = true + // Nothing left of an earlier focus's unwind: while armed the compensation is measured from + // the keyboard, and this is what it falls back to when the arming ends. + unwind.snapTo(0f) + } + + /** + * Ends the pin, giving the height back over [UNWIND_MS] rather than in one frame. + * + * The keyboard outlives the focus — confirming an edit keeps it for the next message, cancelling + * leaves the composer focused — so the transcript has to end up where a keyboard-up transcript + * belongs, which is the pinned row's own height above where the pin was holding it. Dropping the + * compensation is what puts it there; animating the drop is what stops that being a jump. + * + * Handing the amount to the scroll position instead would hold the row still now and cost the + * transcript a keyboard's worth of history later, when the keyboard closes and the height comes + * back with the scroll offset still carrying it. + */ + suspend fun release(imeBottom: Int, bottomPad: Int) { + val carried = compensation(imeBottom, bottomPad).toFloat() + // Ordered so the measured height never changes across the handover: still the same number, + // read from the other branch. + unwind.snapTo(carried) + active = false + if (carried > 0f) unwind.animateTo(0f, tween(UNWIND_MS)) + } + + /** + * Extra height the list needs right now to hold the focused row still, in px. + * + * Two things move the row when the keyboard opens, and both are measured from the start of the + * focus. The keyboard shortens the list, lifting the bottom-anchored content by its height. The + * input bar the list is padded for gets shorter at the same time: it is inside the scaffold's + * `imePadding`, and its own `navigationBarsPadding` subtracts insets already consumed there, so + * the navigation bar it was padding for goes to zero the moment the keyboard covers it. Less + * bottom padding drops the content back down by that much. Handing back the sum of the two + * cancels both; correcting only for the keyboard leaves the row sitting a navigation bar low. + */ + fun compensation(imeBottom: Int, bottomPad: Int): Int = + if (active) { + ((imeBottom - imeAtStart) + (bottomPad - bottomPadAtStart)).coerceIn(0, slack) + } else { + unwind.value.toInt() + } + + private companion object { + const val UNWIND_MS = 200 + } +} + +/** + * The pin for [focusedMessageId] — the selected message, or the one being edited. + * + * Selecting and then editing is one focus, not two: the id doesn't change across that step, so the + * pin isn't re-armed and the measurements it took at the long-press still describe the row. + */ +@Composable +internal fun rememberFocusPin( + listState: LazyListState, + messages: LazyPagingItems, + focusedMessageId: Long?, + imeInsets: WindowInsets, + bottomPadPx: Int, +): FocusPin { + val pin = remember { FocusPin() } + val density = LocalDensity.current + + LaunchedEffect(focusedMessageId) { + if (focusedMessageId != null) { + // The focus starts from a long-press, so the row is on screen and still laid out from + // the frame before. Its offset is the distance from the list's content start, which for + // a reverse list is the bottom edge — and the bottom content padding already covers the + // composer, so that distance is the gap between the row and the bar. + val row = listState.layoutInfo.rowFor(messages, focusedMessageId) + pin.arm( + imeBottom = imeInsets.getBottom(density), + bottomPad = bottomPadPx, + slackPx = row?.offset ?: 0, + ) + } else if (pin.active) { + pin.release(imeInsets.getBottom(density), bottomPadPx) + } + } + + return pin +} + +/** + * Measures the transcript [FocusPin.compensation] px taller than it reports, so its bottom-anchored + * content extends behind the keyboard instead of being pushed up by it. The overflow is only ever + * the strip the keyboard covers, so nothing lands where it can be seen. + */ +internal fun Modifier.holdFocusedMessageInPlace( + pin: FocusPin, + imeInsets: WindowInsets, + bottomPadPx: Int, +): Modifier = + layout { measurable, constraints -> + val extra = pin.compensation(imeInsets.getBottom(this), bottomPadPx) + val placeable = measurable.measure( + constraints.copy( + minHeight = constraints.minHeight + extra, + maxHeight = if (constraints.hasBoundedHeight) { + constraints.maxHeight + extra + } else { + constraints.maxHeight + }, + ) + ) + layout(placeable.width, constraints.constrainHeight(placeable.height - extra)) { + placeable.place(0, 0) + } + } + +/** + * The laid-out row carrying [messageId], if it is on screen. Indices past [messages]'s own count are + * the trailing separator and the contact card, which have no message behind them to peek at. + */ +internal fun LazyListLayoutInfo.rowFor( + messages: LazyPagingItems, + messageId: Long, +): LazyListItemInfo? = visibleItemsInfo.firstOrNull { info -> + info.index < messages.itemCount && + (messages.peek(info.index) as? ChatListItem.ContentBubble)?.messageId == messageId +} + +/** + * Signed gap between the row carrying [messageId] and the lower edge of the top bar, in px: + * positive while the row still has that much room to rise, negative once it has passed under the + * bar by that much. Zero when the row is not laid out. + * + * The list is `reverseLayout`, so an item's offset is measured from the bottom of the content area + * and grows as the item rises. The top of that area is [LazyListLayoutInfo.viewportEndOffset] less + * the padding past it — which is the overlap the top bar reports, so the boundary sits level with + * the bar's lower edge rather than behind it. + */ +internal fun LazyListLayoutInfo.headroomAbove( + messages: LazyPagingItems, + messageId: Long, +): Int { + val row = rowFor(messages, messageId) ?: return 0 + return viewportEndOffset - afterContentPadding - (row.offset + row.size) +} 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 24b8d90e2..f4e8aff4e 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 @@ -1,61 +1,43 @@ 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.animateScrollBy 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 import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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.compose.ui.platform.LocalDensity import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemKey import com.flipcash.app.messenger.internal.ChatViewModel -import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer -import androidx.compose.runtime.CompositionLocalProvider import com.flipcash.shared.chat.models.ChatAction import com.flipcash.shared.chat.models.ChatActionHandler import com.flipcash.shared.chat.models.ChatListItem -import com.flipcash.shared.chat.ui.ContentBubble import com.flipcash.shared.chat.models.LocalChatActionHandler -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 @@ -65,7 +47,6 @@ 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, @@ -121,6 +102,28 @@ internal fun MessageList( // and the contact card at the start of history all stop taking taps together. val selecting = state.selection != null || state.editing != null + // A message can be long-pressed while it is running under the top bar, which the fade + // there makes easy to do — and the bar then swaps to the actions for it, so the row the + // backdrop leaves sharp sits behind the buttons acting on it. Bring it down level with the + // bar's lower edge before that happens. Selecting and then editing is one focus, not two, + // so the key doesn't change across that step and the row isn't scrolled twice. + val focusedMessageId = state.editing?.messageId ?: state.selection?.messageId + LaunchedEffect(focusedMessageId) { + if (focusedMessageId == null) return@LaunchedEffect + val buried = -listState.layoutInfo.headroomAbove(messages, focusedMessageId) + if (buried > 0) listState.animateScrollBy(buried.toFloat()) + } + + // Holds the focused message at the position it was long-pressed at, against the keyboard + // shortening the list from below — whether the keyboard came up for the edit or because the + // composer was tapped with the selection bar still showing. See FocusPin. + val imeInsets = WindowInsets.ime + val listBottomPad = CodeTheme.dimens.grid.x2 + contentPadding.calculateBottomPadding() + val listBottomPadPx = with(LocalDensity.current) { listBottomPad.roundToPx() } + val focusPin = rememberFocusPin(listState, messages, focusedMessageId, imeInsets, listBottomPadPx) + // Read live from the auto-scroll effect below, which outlives the composition it launched in. + val editingMessageId by rememberUpdatedState(state.editing?.messageId) + // 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. @@ -136,13 +139,12 @@ internal fun MessageList( } LazyColumn( - // NB: no sheetResignmentBehavior here. That guard is built for top-anchored lists, - // where index0/offset0 is the scroll edge that abuts the sheet's downward dismiss drag. - // This list is reverseLayout=true, so index0/offset0 is the *resting* position (newest), - // and a downward drag there scrolls into history rather than overscrolling. Applying the - // guard would flip the sheet's allowDismiss to false at rest and never cleanly re-enable - // 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. + // NB: no sheetResignmentBehavior, unlike every other scrolling list in the app. The + // conversation is a full-screen destination, not a sheet, so there is no dismiss drag + // for the guard to hand the gesture back to — back is the only way out. It would also + // read this list wrong: the guard expects a top-anchored list, where index0/offset0 is + // the edge a downward drag overscrolls past, and this one is reverseLayout, where + // index0/offset0 is the resting position and a downward drag scrolls into history. modifier = modifier // 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 @@ -155,12 +157,18 @@ internal fun MessageList( else -> keyboard.hide() } } - }, + } + .holdFocusedMessageInPlace(focusPin, imeInsets, listBottomPadPx), state = listState, reverseLayout = true, + // The transcript can't be dragged for as long as the backdrop is up — selection and + // edit alike. It is behind the same scrim the taps above dismiss, so a drag there has + // no more business reaching it than a tap does. Arriving messages still move it — see + // the auto-scroll effect below, which caps how far. + userScrollEnabled = !selecting, contentPadding = PaddingValues( top = CodeTheme.dimens.inset + contentPadding.calculateTopPadding(), - bottom = CodeTheme.dimens.grid.x2 + contentPadding.calculateBottomPadding(), + bottom = listBottomPad, start = CodeTheme.dimens.inset, end = CodeTheme.dimens.inset, ), @@ -171,41 +179,13 @@ internal fun MessageList( key = messages.itemKey { it.itemKey } ) { index -> val item = messages[index] ?: return@items - val bottomSpacing = bottomSpacingFor(index, item, messages, separatorConfig) - - val isOutgoing = (item as? ChatListItem.ContentBubble)?.isFromSelf ?: false - - // Message insertion animation — scale from 0.95 + opacity with edge anchor. - // Only animate genuinely new messages (index 0 after initial load). - val shouldAnimate = index == 0 && hasLoaded && item.itemKey !in animatedKeys - if (shouldAnimate) animatedKeys.add(item.itemKey) - var appeared by remember(item.itemKey) { mutableStateOf(!shouldAnimate) } - LaunchedEffect(Unit) { if (!appeared) appeared = true } - val insertionAlpha by animateFloatAsState( - targetValue = if (appeared) 1f else 0f, - animationSpec = ChatAnimations.insertion, - label = "insertAlpha", - ) - val insertionScale by animateFloatAsState( - targetValue = if (appeared) 1f else 0.95f, - animationSpec = ChatAnimations.insertion, - label = "insertScale", - ) - val insertionModifier = Modifier.graphicsLayer { - alpha = insertionAlpha - scaleX = insertionScale - scaleY = insertionScale - transformOrigin = if (isOutgoing) { - TransformOrigin(1f, 0.5f) // anchor trailing - } else { - TransformOrigin(0f, 0.5f) // anchor leading - } - } + // Cross-item bookkeeping, so it stays with the list rather than the row: a message + // animates in once, the first time it is laid out after the initial page. + val animateInsertion = index == 0 && hasLoaded && item.itemKey !in animatedKeys + if (animateInsertion) animatedKeys.add(item.itemKey) 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. @@ -218,125 +198,17 @@ internal fun MessageList( 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", + MessageRow( + index = index, + item = item, + messages = messages, + separatorConfig = separatorConfig, + otherReadPointer = otherReadPointer, + selecting = selecting, + focused = focused, + animateInsertion = animateInsertion, ) - - Box( - modifier = Modifier - .padding(bottom = bottomSpacing) - // Unbounded: the rectangle treatment would clip the blur at the row's own - // edges and leave a hard seam between neighbouring rows. - .blur(dimBlur, BlurredEdgeTreatment.Unbounded) - .graphicsLayer { - alpha = dimAlpha - scaleX = lift - scaleY = lift - // Anchored to the bubble's own edge, as the insertion animation is, so - // the lift grows the bubble in place instead of sliding it inward. - transformOrigin = if (isOutgoing) { - TransformOrigin(1f, 0.5f) - } else { - TransformOrigin(0f, 0.5f) - } - } - // 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) { - DateSeparatorRow(item.timestamp) - } - - is ChatListItem.ContentBubble -> { - val effectiveStatus = effectiveReceiptStatus(item, otherReadPointer) - // Track whether this item was ever seen as SENDING so we - // can animate the receipt label entrance on the - // SENDING→SENT transition. This remember persists across - // recompositions of the same item (keyed by LazyColumn), - // surviving the status change that gates the label. - var wasSending by remember { mutableStateOf(false) } - if (item.receiptStatus == ReceiptStatus.SENDING) { - wasSending = true - } - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start, - ) { - 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, - messages, - separatorConfig - ), - ) - } - val showReceipt = - shouldShowReceiptLabel(index, item, messages, otherReadPointer) - AnimatedVisibility( - visible = showReceipt && effectiveStatus != null, - enter = EnterTransition.None, - exit = ChatAnimations.receiptExit, - ) { - if (effectiveStatus != null) { - ReceiptLabel( - status = effectiveStatus, - readPointer = otherReadPointer, - animateEntrance = wasSending, - onRetryFailed = if (effectiveStatus == ReceiptStatus.FAILED) { - { onAction(ChatAction.RetryMessage(item)) } - } else null, - ) - } - } - } - } - } - } } // Show trailing separator and contact info once messages are available @@ -403,6 +275,20 @@ internal fun MessageList( // Always scroll for own messages; only near-bottom for incoming val nearBottom = listState.firstVisibleItemIndex <= 5 val newest = messages.peek(0) as? ChatListItem.ContentBubble + val editing = editingMessageId + if (editing != null) { + // A message arriving mid-edit still moves the transcript, or it would land + // off the bottom edge with nothing to say it had arrived. What it can't do + // is carry the row being edited away: the scroll stops at the point that + // row would pass under the top bar, and the pin holds it there. + if (newest?.isFromSelf == true || nearBottom) { + val room = listState.layoutInfo.headroomAbove(messages, editing) + // Negative is toward index 0. The list settles on the newest message by + // itself if that comes before the cap. + if (room > 0) listState.animateScrollBy(-room.toFloat()) + } + return@collectLatest + } when { // Own message: anchor the new bubble during the next measure pass // rather than animating to it. An animated scroll resolves its target @@ -431,160 +317,5 @@ internal fun MessageList( listState.requestScrollToItem(0, 0) } } - } // CompositionLocalProvider } - -@Composable -private fun bottomSpacingFor( - index: Int, - item: ChatListItem, - messages: LazyPagingItems, - config: SeparatorConfig, -): Dp { - val tight = CodeTheme.dimens.grid.x1 - val normal = CodeTheme.dimens.grid.x2 - val wide = CodeTheme.dimens.grid.x3 - // index-1 is the item below (newer) in reverseLayout - val itemBelow = (if (index > 0) messages.peek(index - 1) else null) ?: return tight - - // Separator adjacent → normal gap - if (item is ChatListItem.DateSeparator || itemBelow is ChatListItem.DateSeparator) { - return normal - } - - val current = item as? ChatListItem.ContentBubble ?: return tight - val below = itemBelow as? ChatListItem.ContentBubble ?: return tight - - return when { - // Different sender → wide - current.isFromSelf != below.isFromSelf -> wide - // Same sender, outside grouping window → normal - !config.isGrouped(current.timestamp, below.timestamp) -> normal - // Same sender, close together → tight - else -> tight - } -} - -@Composable -private fun HandleMessageReads( - listState: LazyListState, - messages: LazyPagingItems, -) { - val actionHandler = LocalChatActionHandler.current - var lastAdvanced by remember { mutableLongStateOf(0L) } - - LaunchedEffect(listState, messages) { - snapshotFlow { - val layout = listState.layoutInfo - val count = messages.itemCount - val visibleRange = layout.visibleItemsInfo - if (visibleRange.isEmpty() || count == 0) return@snapshotFlow null - - var highestId = 0L - for (info in visibleRange) { - if (info.index !in 0 until count) continue - val bubble = messages.peek(info.index) as? ChatListItem.ContentBubble ?: continue - if (!bubble.isFromSelf && bubble.messageId > highestId) { - highestId = bubble.messageId - } - } - if (highestId > 0L) highestId else null - } - .filterNotNull() - .distinctUntilChanged() - .collectLatest { messageId -> - if (messageId > lastAdvanced) { - lastAdvanced = messageId - actionHandler(ChatAction.AdvanceReadPointer(messageId)) - } - } - } -} - -/** - * A tombstone anchors nothing. The receipt describes the delivery of a message that is no longer - * there, so leaving it attached would caption a deleted bubble with "Read". - */ -private val ChatListItem.ContentBubble.carriesReceipt: Boolean - get() = isFromSelf && content !is MessageContent.Deleted - -private fun effectiveReceiptStatus( - bubble: ChatListItem.ContentBubble, - otherReadPointer: MessagePointer?, -): ReceiptStatus? { - if (!bubble.carriesReceipt) return null - val base = bubble.receiptStatus ?: return null - val pointerValue = otherReadPointer?.value ?: 0L - if (base == ReceiptStatus.SENT && bubble.messageId in 1..pointerValue) { - return ReceiptStatus.READ - } - return base -} - -/** - * Show a receipt label below a self-message only within the last 2 contiguous - * self-message groups. In reverseLayout, index 0 is the newest message. - * - * Within a group, labels appear at status boundaries (SENT↔READ). - * At group boundaries, labels are suppressed when the nearest self-group - * below already shows the same status (avoids duplicate "Read" labels). - */ -/** The nearest bubble below [index], stepping over the viewer's own tombstones. */ -private fun receiptNeighbourBelow( - index: Int, - messages: LazyPagingItems, -): ChatListItem.ContentBubble? { - for (i in (index - 1) downTo 0) { - val bubble = messages.peek(i) as? ChatListItem.ContentBubble ?: return null - if (bubble.isFromSelf && bubble.content is MessageContent.Deleted) continue - return bubble - } - return null -} - -private fun shouldShowReceiptLabel( - index: Int, - item: ChatListItem.ContentBubble, - messages: LazyPagingItems, - otherReadPointer: MessagePointer?, -): Boolean { - if (!item.carriesReceipt) return false - val status = effectiveReceiptStatus(item, otherReadPointer) ?: return false - if (status == ReceiptStatus.FAILED) return true - if (status != ReceiptStatus.SENT && status != ReceiptStatus.READ) return false - - // index - 1 is the item below (newer) in reverseLayout. A tombstone still belongs to the self - // group for bubble shaping, so it is stepped over here rather than treated as a group boundary. - val belowBubble = receiptNeighbourBelow(index, messages) - - // Within a self-group: show at intra-group status boundaries only - if (belowBubble != null && belowBubble.isFromSelf) { - return effectiveReceiptStatus(belowBubble, otherReadPointer) != status - } - - // At a group boundary — count which self-group this is. - var selfGroups = 0 - var prevWasSelf = false - for (i in 0 until index) { - val peek = messages.peek(i) ?: break - val bubble = peek as? ChatListItem.ContentBubble - val isSelf = bubble != null && bubble.isFromSelf - if (isSelf && !prevWasSelf) selfGroups++ - prevWasSelf = isSelf - } - if (!prevWasSelf) selfGroups++ // current item starts a new group - if (selfGroups > 2) return false - - // Bottommost self-group always shows its label - if (selfGroups == 1) return true - - // For group 2: show only if status differs from the nearest self-group below - for (i in (index - 1) downTo 0) { - val peek = messages.peek(i) ?: break - val bubble = peek as? ChatListItem.ContentBubble ?: continue - if (bubble.carriesReceipt) return effectiveReceiptStatus(bubble, otherReadPointer) != status - } - return true -} - diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt new file mode 100644 index 000000000..7d6323cad --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt @@ -0,0 +1,57 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.paging.compose.LazyPagingItems +import com.flipcash.shared.chat.models.ChatAction +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.LocalChatActionHandler +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull + +/** + * Reports the newest incoming message the viewer has actually had on screen, so the read pointer + * follows the scroll rather than the arrival. + */ +@Composable +internal fun HandleMessageReads( + listState: LazyListState, + messages: LazyPagingItems, +) { + val actionHandler = LocalChatActionHandler.current + var lastAdvanced by remember { mutableLongStateOf(0L) } + + LaunchedEffect(listState, messages) { + snapshotFlow { + val layout = listState.layoutInfo + val count = messages.itemCount + val visibleRange = layout.visibleItemsInfo + if (visibleRange.isEmpty() || count == 0) return@snapshotFlow null + + var highestId = 0L + for (info in visibleRange) { + if (info.index !in 0 until count) continue + val bubble = messages.peek(info.index) as? ChatListItem.ContentBubble ?: continue + if (!bubble.isFromSelf && bubble.messageId > highestId) { + highestId = bubble.messageId + } + } + if (highestId > 0L) highestId else null + } + .filterNotNull() + .distinctUntilChanged() + .collectLatest { messageId -> + if (messageId > lastAdvanced) { + lastAdvanced = messageId + actionHandler(ChatAction.AdvanceReadPointer(messageId)) + } + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt new file mode 100644 index 000000000..bb1f0d7d4 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -0,0 +1,250 @@ +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 androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.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.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.paging.compose.LazyPagingItems +import com.flipcash.app.messenger.internal.screens.ChatAnimations +import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.shared.chat.models.ChatAction +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.LocalChatActionHandler +import com.flipcash.shared.chat.models.ReceiptStatus +import com.flipcash.shared.chat.models.SeparatorConfig +import com.flipcash.shared.chat.ui.ContentBubble +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 + +/** + * One row of the transcript: a date separator, or a bubble with the receipt label that can sit under + * it. + * + * The row owns what is a function of itself — its insertion animation, its gestures, its spacing to + * the row below — and takes the rest as flags, because they are decided across the whole list: + * [selecting] is true for every row while the backdrop is up, [focused] for the single row it leaves + * sharp, and [animateInsertion] is granted once per message and never again. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun MessageRow( + index: Int, + item: ChatListItem, + messages: LazyPagingItems, + separatorConfig: SeparatorConfig, + otherReadPointer: MessagePointer?, + selecting: Boolean, + focused: Boolean, + animateInsertion: Boolean, +) { + val onAction = LocalChatActionHandler.current + val vibrator = LocalVibrator.current + val keyboard = rememberKeyboardController() + val bottomSpacing = bottomSpacingFor(index, item, messages, separatorConfig) + + val isOutgoing = (item as? ChatListItem.ContentBubble)?.isFromSelf ?: false + + // Message insertion animation — scale from 0.95 + opacity with edge anchor. + var appeared by remember(item.itemKey) { mutableStateOf(!animateInsertion) } + LaunchedEffect(Unit) { if (!appeared) appeared = true } + val insertionAlpha by animateFloatAsState( + targetValue = if (appeared) 1f else 0f, + animationSpec = ChatAnimations.insertion, + label = "insertAlpha", + ) + val insertionScale by animateFloatAsState( + targetValue = if (appeared) 1f else 0.95f, + animationSpec = ChatAnimations.insertion, + label = "insertScale", + ) + + val insertionModifier = Modifier.graphicsLayer { + alpha = insertionAlpha + scaleX = insertionScale + scaleY = insertionScale + transformOrigin = if (isOutgoing) { + TransformOrigin(1f, 0.5f) // anchor trailing + } else { + TransformOrigin(0f, 0.5f) // anchor leading + } + } + + val bubble = item as? ChatListItem.ContentBubble + val interactionSource = remember { MutableInteractionSource() } + + 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) + // 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) { + DateSeparatorRow(item.timestamp) + } + + is ChatListItem.ContentBubble -> { + val effectiveStatus = effectiveReceiptStatus(item, otherReadPointer) + // Track whether this item was ever seen as SENDING so we + // can animate the receipt label entrance on the + // SENDING→SENT transition. This remember persists across + // recompositions of the same item (keyed by LazyColumn), + // surviving the status change that gates the label. + var wasSending by remember { mutableStateOf(false) } + if (item.receiptStatus == ReceiptStatus.SENDING) { + wasSending = true + } + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start, + ) { + 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, + messages, + separatorConfig + ), + ) + } + val showReceipt = + shouldShowReceiptLabel(index, item, messages, otherReadPointer) + AnimatedVisibility( + visible = showReceipt && effectiveStatus != null, + enter = EnterTransition.None, + exit = ChatAnimations.receiptExit, + ) { + if (effectiveStatus != null) { + ReceiptLabel( + status = effectiveStatus, + readPointer = otherReadPointer, + animateEntrance = wasSending, + onRetryFailed = if (effectiveStatus == ReceiptStatus.FAILED) { + { onAction(ChatAction.RetryMessage(item)) } + } else null, + ) + } + } + } + } + } + } +} + +@Composable +private fun bottomSpacingFor( + index: Int, + item: ChatListItem, + messages: LazyPagingItems, + config: SeparatorConfig, +): Dp { + val tight = CodeTheme.dimens.grid.x1 + val normal = CodeTheme.dimens.grid.x2 + val wide = CodeTheme.dimens.grid.x3 + // index-1 is the item below (newer) in reverseLayout + val itemBelow = (if (index > 0) messages.peek(index - 1) else null) ?: return tight + + // Separator adjacent → normal gap + if (item is ChatListItem.DateSeparator || itemBelow is ChatListItem.DateSeparator) { + return normal + } + + val current = item as? ChatListItem.ContentBubble ?: return tight + val below = itemBelow as? ChatListItem.ContentBubble ?: return tight + + return when { + // Different sender → wide + current.isFromSelf != below.isFromSelf -> wide + // Same sender, outside grouping window → normal + !config.isGrouped(current.timestamp, below.timestamp) -> normal + // Same sender, close together → tight + else -> tight + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt new file mode 100644 index 000000000..f003421fb --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt @@ -0,0 +1,93 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.paging.compose.LazyPagingItems +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.ReceiptStatus + +/** + * A tombstone anchors nothing. The receipt describes the delivery of a message that is no longer + * there, so leaving it attached would caption a deleted bubble with "Read". + */ +private val ChatListItem.ContentBubble.carriesReceipt: Boolean + get() = isFromSelf && content !is MessageContent.Deleted + +internal fun effectiveReceiptStatus( + bubble: ChatListItem.ContentBubble, + otherReadPointer: MessagePointer?, +): ReceiptStatus? { + if (!bubble.carriesReceipt) return null + val base = bubble.receiptStatus ?: return null + val pointerValue = otherReadPointer?.value ?: 0L + if (base == ReceiptStatus.SENT && bubble.messageId in 1..pointerValue) { + return ReceiptStatus.READ + } + return base +} + +/** + * Show a receipt label below a self-message only within the last 2 contiguous + * self-message groups. In reverseLayout, index 0 is the newest message. + * + * Within a group, labels appear at status boundaries (SENT↔READ). + * At group boundaries, labels are suppressed when the nearest self-group + * below already shows the same status (avoids duplicate "Read" labels). + */ +/** The nearest bubble below [index], stepping over the viewer's own tombstones. */ +private fun receiptNeighbourBelow( + index: Int, + messages: LazyPagingItems, +): ChatListItem.ContentBubble? { + for (i in (index - 1) downTo 0) { + val bubble = messages.peek(i) as? ChatListItem.ContentBubble ?: return null + if (bubble.isFromSelf && bubble.content is MessageContent.Deleted) continue + return bubble + } + return null +} + +internal fun shouldShowReceiptLabel( + index: Int, + item: ChatListItem.ContentBubble, + messages: LazyPagingItems, + otherReadPointer: MessagePointer?, +): Boolean { + if (!item.carriesReceipt) return false + val status = effectiveReceiptStatus(item, otherReadPointer) ?: return false + if (status == ReceiptStatus.FAILED) return true + if (status != ReceiptStatus.SENT && status != ReceiptStatus.READ) return false + + // index - 1 is the item below (newer) in reverseLayout. A tombstone still belongs to the self + // group for bubble shaping, so it is stepped over here rather than treated as a group boundary. + val belowBubble = receiptNeighbourBelow(index, messages) + + // Within a self-group: show at intra-group status boundaries only + if (belowBubble != null && belowBubble.isFromSelf) { + return effectiveReceiptStatus(belowBubble, otherReadPointer) != status + } + + // At a group boundary — count which self-group this is. + var selfGroups = 0 + var prevWasSelf = false + for (i in 0 until index) { + val peek = messages.peek(i) ?: break + val bubble = peek as? ChatListItem.ContentBubble + val isSelf = bubble != null && bubble.isFromSelf + if (isSelf && !prevWasSelf) selfGroups++ + prevWasSelf = isSelf + } + if (!prevWasSelf) selfGroups++ // current item starts a new group + if (selfGroups > 2) return false + + // Bottommost self-group always shows its label + if (selfGroups == 1) return true + + // For group 2: show only if status differs from the nearest self-group below + for (i in (index - 1) downTo 0) { + val peek = messages.peek(i) ?: break + val bubble = peek as? ChatListItem.ContentBubble ?: continue + if (bubble.carriesReceipt) return effectiveReceiptStatus(bubble, otherReadPointer) != status + } + return true +}