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
Expand Up @@ -5,13 +5,7 @@ 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
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
Expand Down Expand Up @@ -49,7 +43,6 @@ 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
Expand All @@ -62,16 +55,13 @@ 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.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.getcode.ui.components.chat.TypingIndicator
import com.getcode.ui.core.drawWithGradient
import com.getcode.ui.core.measured
import com.getcode.ui.utils.rememberKeyboardController
Expand Down Expand Up @@ -146,47 +136,6 @@ internal fun UserControlBottomBar(
modifier = Modifier
.fillMaxWidth(),
) {
// Typing indicator entry/exit — scale from 0.95 anchored leading + opacity
// (same as message bubble insertion, no vertical slide)
AnimatedContent(
modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset),
targetState = state.typists.isNotEmpty(),
transitionSpec = {
(scaleIn(
ChatAnimations.typingIndicator,
initialScale = 0.95f,
transformOrigin = TransformOrigin(0f, 0.5f)
) + fadeIn(ChatAnimations.typingIndicator)) togetherWith
(scaleOut(
ChatAnimations.typingIndicator,
targetScale = 0.95f,
transformOrigin = TransformOrigin(0f, 0.5f)
) + fadeOut(ChatAnimations.typingIndicator)) using
// The container shrinks to nothing as the indicator leaves; unclipped, the
// indicator fades out whole instead of being cut off by the shrinking bounds.
SizeTransform(clip = false)
}
) { show ->
if (show) {
TypingIndicator(
typists = state.typingAvatars,
key = { it.key },
modifier = Modifier
.hazeBlur(HazeInput.Sources(hazeState), material),
) { typist ->
// Drawn the way the transcript's sender gutter draws the same member, so the
// face beside the dots is the face beside their messages. A typist whose
// profile hasn't resolved goes through the same call with no picture and no
// name, rather than a separate unknown-person glyph.
val profile = typist.profile
ContactAvatar(
image = profile?.profilePicture,
displayName = profile?.displayName.orEmpty(),
access = BlobAccessContext.profile(typist.userId),
)
}
}
}
// 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
package com.flipcash.app.messenger.internal.screens.components

import androidx.compose.animation.core.Animatable
import com.getcode.ui.core.addIf
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
import kotlinx.coroutines.coroutineScope
import androidx.compose.foundation.layout.Column
import androidx.compose.animation.core.tween
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.SideEffect
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.gestures.animateScrollBy
Expand Down Expand Up @@ -349,6 +357,64 @@ internal fun MessageList(
mutableStateMapOf<Any, IntRect>()
}

// The typing dots, drawn under the newest row rather than as a row of their own: the paged
// indices stay the lazy indices every helper here assumes, and the newest row opening and
// closing at its lower edge moves the transcript the way the insertion push does. See
// TypingRowModel for which arrival grows out of them.
val typingRow = remember { TypingRowModel() }
val dotsPresence = remember { Animatable(0f) }
val typingScope = rememberCoroutineScope()
val density = LocalDensity.current
val typists = state.typists
val arriveUnderDots = {
typingRow.arrive(
typing = typists,
messages = messages,
dotsHeightPx = {
Snapshot.withoutReadObservation { (typingRow.slotPx * dotsPresence.value).roundToInt() }
},
resting = { key, index -> Snapshot.withoutReadObservation { listState.restsOn(key, index) } },
)
}
val typingFrame = typingRow.update(typing = typists, arrive = arriveUnderDots)
if (typingFrame.anchorNewest) {
// Before the layout that places the arrival, which would otherwise hold the previous
// newest row where it sat and leave the arrival under the bottom edge.
SideEffect { listState.requestScrollToItem(0, 0) }
}
if (typingFrame.handedOff) {
// The message took the dots' place, so there is nothing left of them to close. Not an
// effect keyed on the frame, which the next recomposition would cancel.
SideEffect { typingScope.launch { dotsPresence.snapTo(0f) } }
}
val showDots = typingFrame.typists.isNotEmpty()
val dotsVisible by remember { derivedStateOf { dotsPresence.value > 0f } }
val dotsAvatars = if (isGroup) {
state.typingAvatars.filter { it.userId in typingFrame.typists }
} else {
emptyList()
}
LaunchedEffect(showDots) {
val target = if (showDots) 1f else 0f
if (dotsPresence.value == target) return@LaunchedEffect
if (listState.restsOnNewest()) {
// Resting on the newest row, the dots open and close it, and the rows above ride
// with them on the insertion push's spring.
dotsPresence.animateTo(target, ChatAnimations.typingRowHeight)
} else {
// Scrolled up, nothing on screen moves for them: only the newest row changes
// height, and the scroll takes that change back.
val delta = ((target - dotsPresence.value) * typingRow.slotPx).roundToInt()
dotsPresence.snapTo(target)
if (listState.firstVisibleItemIndex == 0 && delta != 0) {
listState.requestScrollToItem(
0,
(listState.firstVisibleItemScrollOffset + delta).coerceAtLeast(0),
)
}
}
}

LazyColumn(
// 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
Expand Down Expand Up @@ -393,7 +459,19 @@ internal fun MessageList(

// 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
// The newest row opening from under the typing dots: see TypingRowModel.
// Decided here too: the list can lay this row out before the composition above
// has seen it arrive. See TypingRowModel.arrive.
val grow = if (index == 0) {
arriveUnderDots()
if (typingRow.takeAnchor()) SideEffect { listState.requestScrollToItem(0, 0) }
typingRow.growFor(item.itemKey)
} else {
null
}
val animateInsertion = index == 0 && hasLoaded && item.itemKey !in animatedKeys &&
// A message growing out of the dots doesn't rise in as well.
grow?.handoff == null
if (animateInsertion) animatedKeys.add(item.itemKey)

val bubble = item as? ChatListItem.ContentBubble
Expand Down Expand Up @@ -423,6 +501,52 @@ internal fun MessageList(
}
val hostsStrip = inSelection && item.itemKey == state.selection?.itemKey

var growing by remember { mutableStateOf(grow != null) }
val growProgress = remember { Animatable(if (grow != null) 0f else 1f) }
val contentAlpha = remember { Animatable(if (grow?.handoff != null) 0f else 1f) }
val dotsAlpha = remember { Animatable(if (grow?.handoff != null) 1f else 0f) }
LaunchedEffect(grow) {
grow ?: return@LaunchedEffect
coroutineScope {
val spec = if (grow.handoff != null) {
ChatAnimations.fromTyping
} else {
ChatAnimations.insertionPush
}
launch { growProgress.animateTo(1f, spec) }
if (grow.handoff != null) {
launch {
contentAlpha.animateTo(
1f,
tween(
durationMillis = ChatAnimations.fromTypingTextFadeMs,
delayMillis = ChatAnimations.fromTypingTextDelayMs,
),
)
}
launch { dotsAlpha.animateTo(0f, tween(ChatAnimations.fromTypingDotsFadeMs)) }
}
}
growing = false
typingRow.finished(grow.key)
}
val handoff = grow?.handoff?.takeIf { growing }?.let { from ->
remember(from) {
TypingHandoff(
from = from,
progress = { growProgress.value },
contentAlpha = { contentAlpha.value },
dotsAlpha = { dotsAlpha.value },
)
}
}

// Every item is wrapped alike, so a row keeps its state as it moves off index 0.
Column(
modifier = Modifier.addIf(grow != null && growing) {
Modifier.openFromTypingRow(grow?.fromPx ?: 0) { growProgress.value }
},
) {
MessageRow(
index = index,
item = item,
Expand Down Expand Up @@ -453,7 +577,27 @@ internal fun MessageList(
} else {
NoAttention
},
typingHandoff = handoff,
)

// Under the newest row, not in place of it. A message growing out of the dots
// draws what is left of them itself.
if (index == 0 && (showDots || dotsVisible) && !typingFrame.handedOff && handoff == null) {
TypingRow(
avatars = dotsAvatars,
// The newest row already keeps the tight gap under it; the dots add the rest.
gapAbove = when (gapAboveTypingRow(item, typingFrame.lastTypists)) {
RowGap.Tight -> 0.dp
RowGap.Normal -> CodeTheme.dimens.grid.x2 - CodeTheme.dimens.grid.x1
RowGap.Wide -> CodeTheme.dimens.grid.x3 - CodeTheme.dimens.grid.x1
},
model = typingRow,
presence = { dotsPresence.value },
// Dots carried under a newer message keep their place.
enters = Snapshot.withoutReadObservation { dotsPresence.value == 0f },
)
}
}
}

// Show trailing separator and contact info once messages are available
Expand Down Expand Up @@ -571,12 +715,15 @@ internal fun MessageList(
}
.filterNotNull()
.distinctUntilChanged()
.collectLatest {
.collectLatest { newestKey ->
// 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()
}
// Opening from under the typing dots already moves the transcript. Checked after
// the layout, by which point the composition that decided it has run.
if (typingRow.grows(newestKey)) return@collectLatest
// 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.
Expand Down Expand Up @@ -768,6 +915,21 @@ private suspend fun LazyListState.alignBelowTopBar(index: Int) {
scrollToItem(index, scrollOffset = -(band.last - band.first - height).coerceAtLeast(0))
}

/** Whether the transcript rests on its newest row, with nothing of it scrolled out of view. */
private fun LazyListState.restsOnNewest(): Boolean =
firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0

/**
* Whether the transcript rests on the row keyed [key], which a new page has moved to [index]. The
* list re-anchors on that key when it measures the new page, so this holds both before that measure
* (still at 0, as the last layout placed it) and during it (already at [index]).
*/
private fun LazyListState.restsOn(key: Any, index: Int): Boolean =
firstVisibleItemScrollOffset == 0 && (
firstVisibleItemIndex == index ||
(firstVisibleItemIndex == 0 && layoutInfo.visibleItemsInfo.firstOrNull()?.key == key)
)

/** The presented index of [messageId], or `null` while it is still unloaded. */
private fun indexOf(messages: LazyPagingItems<ChatListItem>, messageId: Long): Int? =
(0 until messages.itemCount).firstOrNull { i ->
Expand Down Expand Up @@ -842,3 +1004,4 @@ 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