Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package com.flipcash.app.messenger.internal.screens.components

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
Expand All @@ -17,10 +18,17 @@ 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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imeAnimationSource
import androidx.compose.foundation.layout.imeAnimationTarget
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
Expand All @@ -39,26 +47,30 @@ 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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import com.flipcash.app.messenger.internal.ChatSubject
import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.app.messenger.internal.balanceRequirement
import com.flipcash.app.messenger.internal.requiresStaff
import com.flipcash.features.messenger.R
import com.flipcash.services.models.chat.BlobAccessContext
import com.flipcash.services.models.chat.ChatType
import com.flipcash.shared.chat.models.ChatActionHandler
import com.flipcash.shared.chat.ui.ChatAnimations
import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.models.chat.BlobAccessContext
import com.flipcash.features.messenger.R
import com.getcode.theme.CodeTheme
import com.flipcash.shared.chat.ui.ComposerReplyStrip
import com.flipcash.shared.common.ui.ContactAvatar
import com.getcode.theme.CodeTheme
import com.getcode.ui.components.chat.ChatInput
import com.getcode.ui.components.chat.ChatInputSubmit
import com.flipcash.shared.common.ui.ContactAvatar
import com.getcode.ui.components.chat.TypingIndicator
import com.getcode.ui.core.drawWithGradient
import com.getcode.ui.core.measured
Expand All @@ -68,6 +80,27 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.hazeBlur
import dev.chrisbanes.haze.blur.materials.HazeMaterials

/** The ramp's shape: clear for most of its height, then a late rise into the background. */
private val ComposerFadeCurve = CubicBezierEasing(0.42f, 0.18f, 0.17f, 1.04f)

/** Short of opaque, so a bubble behind the bar's bottom edge still shows faintly. */
private const val ComposerFadeEndAlpha = 0.9f

private const val ComposerFadeSamples = 32

/** Sampled once: the curve has no gradient-stop equivalent, so it is laid down as stops. */
private val ComposerFadeStops: List<Pair<Float, Float>> = (0..ComposerFadeSamples).map { i ->
val t = i / ComposerFadeSamples.toFloat()
t to (ComposerFadeEndAlpha * ComposerFadeCurve.transform(t)).coerceIn(0f, 1f)
}

/** The dissolve from the transcript into [color] behind the composer, from [startY] to [endY]. */
private fun composerFade(color: Color, startY: Float, endY: Float): Brush = Brush.verticalGradient(
colorStops = ComposerFadeStops.map { (t, alpha) -> t to color.copy(alpha = alpha) }.toTypedArray(),
startY = startY,
endY = endY,
)

@Composable
internal fun UserControlBottomBar(
state: ChatViewModel.State,
Expand Down Expand Up @@ -154,20 +187,51 @@ internal fun UserControlBottomBar(
}
}
}
// Compact at rest: with the keyboard down the composer sits narrower and a little lower, into
// the navigation bar's inset, and opens out to the normal margins as the keyboard comes up.
// Only once there is a composer; the full-width Send Cash button keeps its width.
//
// Tracks how far open the keyboard is rather than springing on a keyboard up/down flag, so
// the bar changes size in step with the keyboard's own motion: not ahead of it (a flag that
// flips as the hide starts), and not after it (one that waits for the hide to finish). The
// composer appearing at all (typingConstraints resolving a frame after the bar is revealed)
// doesn't move anything, since nothing here animates on its own.
val keyboardOpen = keyboardOpenFraction()
val hasComposer = state.typingConstraints.enabled
val compactInset = CodeTheme.dimens.grid.x6.coerceAtLeast(CodeTheme.dimens.inset)
val sideInset = if (hasComposer) lerp(compactInset, CodeTheme.dimens.inset, keyboardOpen) else CodeTheme.dimens.inset
val restingDrop = if (hasComposer) lerp(CodeTheme.dimens.grid.x2, 0.dp, keyboardOpen) else 0.dp
// The part of the navigation bar's inset the keyboard isn't covering, so the bar only sinks
// into it once the keyboard has cleared it.
val restingRoom = WindowInsets.navigationBars.exclude(WindowInsets.ime)
Box {
// The transcript runs under the bar and dissolves into the background here, from the
// bar's top edge to the bottom of the screen (or the keyboard's top edge). The bar has no
// surface of its own and floats over it.
val fadeColor = CodeTheme.colors.background
Box(
modifier = Modifier
.fillMaxWidth()
.height(buttonHeight)
.align(Alignment.BottomCenter)
.drawWithGradient(
color = CodeTheme.colors.background,
brush = { startY, endY -> composerFade(fadeColor, startY, endY) },
startY = { 0f },
),
)
AnimatedContent(
modifier = Modifier
.measured { buttonHeight = it.height }
// Reports itself shorter by the drop and lets the controls run past its bottom
// edge, so the bar sits that far into the navigation bar's inset and the
// transcript's bottom padding (measured from this height) follows it down.
.layout { measurable, constraints ->
val drop = restingDrop.roundToPx().coerceAtMost(restingRoom.getBottom(this))
val placeable = measurable.measure(constraints)
layout(placeable.width, (placeable.height - drop).coerceAtLeast(0)) {
placeable.place(0, 0)
}
}
.padding(vertical = CodeTheme.dimens.grid.x3)
.navigationBarsPadding()
// typingConstraints.enabled starts false and only resolves a frame or two after
Expand Down Expand Up @@ -231,8 +295,8 @@ internal fun UserControlBottomBar(
hazeState = hazeState,
modifier = Modifier
// Inset to the composer row's own margins, so the card's edges
// line up with the field it sits above.
.padding(horizontal = CodeTheme.dimens.inset)
// line up with the field it sits above, and narrows with it.
.padding(horizontal = sideInset)
.padding(bottom = CodeTheme.dimens.grid.x2)
.testTag("composer_reply_strip"),
)
Expand All @@ -241,7 +305,7 @@ internal fun UserControlBottomBar(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = CodeTheme.dimens.inset),
.padding(horizontal = sideInset),
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
verticalAlignment = Alignment.Bottom,
) {
Expand Down Expand Up @@ -375,4 +439,21 @@ private fun DeactivatedChatBottomBar() {
textAlign = TextAlign.Center,
)
}
}
}

/**
* How far open the keyboard is, from 0 (down) to 1 (fully up), following its show and hide
* animations frame by frame.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun keyboardOpenFraction(): Float {
val density = LocalDensity.current
val now = WindowInsets.ime.getBottom(density)
val full = maxOf(
WindowInsets.imeAnimationSource.getBottom(density),
WindowInsets.imeAnimationTarget.getBottom(density),
now,
)
return if (full <= 0) 0f else now.toFloat() / full
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,16 @@ 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.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.dp
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.lerp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
Expand All @@ -64,6 +65,7 @@ import com.flipcash.shared.chat.models.ChatListItem
import com.flipcash.shared.chat.models.LinkCardResolution
import com.flipcash.shared.chat.models.LocalChatActionHandler
import com.flipcash.shared.chat.models.LocalLinkCardResolution
import com.flipcash.shared.chat.models.ReceiptStatus
import com.flipcash.shared.chat.models.SeparatorConfig
import com.flipcash.shared.chat.reactions.ReactionRefreshPlanner
import com.flipcash.shared.chat.ui.ChatAnimations
Expand All @@ -77,11 +79,13 @@ import com.getcode.util.vibration.LocalVibrator
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration.Companion.milliseconds

@Composable
internal fun MessageList(
Expand Down Expand Up @@ -150,6 +154,7 @@ internal fun MessageList(
// Keys that have already played their insertion animation — persists
// across item disposal so scrolling away and back doesn't replay.
val animatedKeys = remember { mutableSetOf<Any>() }
val settlingKey = rememberSettlingSend(messages)

// 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.
Expand Down Expand Up @@ -258,7 +263,11 @@ internal fun MessageList(
// 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()
// With the keyboard up the newest message sits a little closer to the composer, which no
// longer rests into the navigation bar's inset. Follows the keyboard the same way the
// composer does (see ChatBottomBar), so the two move together.
val raisedDrop = lerp(0.dp, CodeTheme.dimens.grid.x2, keyboardOpenFraction())
val listBottomPad = CodeTheme.dimens.grid.x2 + contentPadding.calculateBottomPadding() - raisedDrop
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.
Expand Down Expand Up @@ -420,6 +429,7 @@ internal fun MessageList(
messages = messages,
separatorConfig = separatorConfig,
otherReadPointer = otherReadPointer,
settlingKey = settlingKey,
selecting = selecting,
focused = focused,
// The pressed row is also the only one that lifts, as iOS lifts only the held bubble.
Expand Down Expand Up @@ -562,6 +572,16 @@ internal fun MessageList(
.filterNotNull()
.distinctUntilChanged()
.collectLatest {
// The paged snapshot changes before the list lays it out, so wait for the next
// layout pass; until then the scroll position describes the old list.
withTimeoutOrNull(NextLayoutTimeoutMs) {
snapshotFlow { listState.layoutInfo }.drop(1).first()
}
// The list holds its position by key, so a message arriving at index 0 leaves
// the one that was newest (now index 1) where it sat. Sitting at offset 0 means
// the transcript was resting on the newest message.
val atBottom = listState.firstVisibleItemIndex == 1 &&
listState.firstVisibleItemScrollOffset == 0
// Always scroll for own messages; only near-bottom for incoming
val nearBottom = listState.firstVisibleItemIndex <= 5
val newest = messages.peek(0) as? ChatListItem.ContentBubble
Expand All @@ -579,6 +599,25 @@ internal fun MessageList(
}
return@collectLatest
}
// Resting on the newest message, the arrival pushes the transcript up as iOS
// does: every row rides up by the new row's height on the insertion spring,
// while the new row rises into its slot. Placement animations can't do this —
// the move is a scroll, and a jump in scroll position resets them.
if (atBottom && (newest?.isFromSelf == true || nearBottom)) {
val height = listState.measuredHeight(0)
if (height != null) {
// Negative is toward index 0.
listState.animateScrollBy(-height.toFloat(), ChatAnimations.insertionPush)
// The row above can resize under the scroll (its receipt moving on), so
// settle exactly on the newest message rather than a few pixels short.
if (listState.firstVisibleItemIndex != 0 ||
listState.firstVisibleItemScrollOffset != 0
) {
listState.requestScrollToItem(0, 0)
}
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
Expand Down Expand Up @@ -610,6 +649,36 @@ internal fun MessageList(
} // CompositionLocalProvider
}

/** How long a send holds the receipt above it, at least; iOS's settle floor. */
private val ReceiptSettleFloor = 700.milliseconds

/**
* The viewer's newest send while it settles, or null. A send settles from the moment it lands at
* the bottom of the transcript until it is confirmed and [ReceiptSettleFloor] has passed, whichever
* is later; until then the receipt stays on the row above (see [shouldShowReceiptLabel]). A send
* that fails, or that a reply lands under, releases once the floor has passed.
*/
@Composable
private fun rememberSettlingSend(messages: LazyPagingItems<ChatListItem>): Any? {
val newest = if (messages.itemCount > 0) messages.peek(0) as? ChatListItem.ContentBubble else null
val currentNewest by rememberUpdatedState(newest)
var sendKey by remember { mutableStateOf<Any?>(null) }
if (newest != null && newest.isFromSelf && newest.receiptStatus == ReceiptStatus.SENDING) {
sendKey = newest.messageKey
}
var settling by remember { mutableStateOf<Any?>(null) }
LaunchedEffect(sendKey) {
val key = sendKey ?: return@LaunchedEffect
settling = key
delay(ReceiptSettleFloor)
snapshotFlow { currentNewest }.first { row ->
row?.messageKey != key || row.receiptStatus != ReceiptStatus.SENDING
}
settling = null
}
return settling
}

/**
* How many items are actually loaded, as opposed to standing in as placeholders.
*
Expand Down Expand Up @@ -767,6 +836,9 @@ private fun LazyListLayoutInfo.centeringBand(): IntRange =
/** Handed to every row that is not the one a jump just landed on. */
private val NoAttention: () -> Float = { 0f }

// A guard on waiting for the list to lay out a newly arrived row: the scroll goes ahead without the
// pass rather than waiting on one that doesn't come.
private const val NextLayoutTimeoutMs = 250L
private const val JUMP_PAGE_SIZE = 50
private const val MAX_JUMP_ITEMS = 5_000
private const val JUMP_STEP_TIMEOUT_MS = 2_000L
Loading
Loading