@@ -14,13 +14,14 @@ import androidx.compose.foundation.lazy.LazyListState
1414import androidx.compose.foundation.lazy.rememberLazyListState
1515import androidx.compose.runtime.Composable
1616import androidx.compose.runtime.LaunchedEffect
17+ import androidx.compose.runtime.derivedStateOf
1718import androidx.compose.runtime.getValue
1819import androidx.compose.runtime.mutableLongStateOf
19- import androidx.compose.runtime.derivedStateOf
2020import androidx.compose.runtime.mutableStateOf
2121import androidx.compose.runtime.remember
2222import androidx.compose.runtime.setValue
2323import androidx.compose.runtime.snapshotFlow
24+ import androidx.compose.runtime.snapshots.Snapshot
2425import androidx.compose.ui.Alignment
2526import androidx.compose.ui.Modifier
2627import androidx.compose.ui.graphics.TransformOrigin
@@ -43,9 +44,10 @@ import com.getcode.ui.utils.sheetResignmentBehavior
4344import com.getcode.util.vibration.LocalVibrator
4445import kotlinx.coroutines.flow.collectLatest
4546import kotlinx.coroutines.flow.distinctUntilChanged
47+ import kotlinx.coroutines.flow.dropWhile
4648import kotlinx.coroutines.flow.filterNotNull
49+ import kotlinx.coroutines.flow.first
4750import kotlinx.coroutines.flow.mapNotNull
48- import kotlin.time.Instant
4951
5052
5153@Composable
@@ -96,6 +98,20 @@ internal fun MessageList(
9698 LaunchedEffect (initialLoadComplete) {
9799 if (initialLoadComplete) hasLoaded = true
98100 }
101+ // Keys that have already played their insertion animation — persists
102+ // across item disposal so scrolling away and back doesn't replay.
103+ val animatedKeys = remember { mutableSetOf<Any >() }
104+
105+ // Track when the initial Paging refresh has truly completed (Loading → NotLoading).
106+ // This avoids showing the ContactInfoContainer before messages arrive,
107+ // which would cause the list to start scrolled to the wrong position.
108+ var refreshSettled by remember { mutableStateOf(false ) }
109+ LaunchedEffect (Unit ) {
110+ snapshotFlow { messages.loadState.refresh }
111+ .dropWhile { it !is LoadState .Loading }
112+ .first { it !is LoadState .Loading }
113+ refreshSettled = true
114+ }
99115
100116 LazyColumn (
101117 modifier = modifier
@@ -125,8 +141,9 @@ internal fun MessageList(
125141 // Message insertion animation — scale from 0.95 + opacity with edge anchor.
126142 // Tuned to match observed iOS timing (~500ms gradual fade-in).
127143 // Only animate genuinely new messages (index 0 after initial load).
128- val shouldAnimate = index == 0 && hasLoaded
129- var appeared by remember { mutableStateOf(! shouldAnimate) }
144+ val shouldAnimate = index == 0 && hasLoaded && item.itemKey !in animatedKeys
145+ if (shouldAnimate) animatedKeys.add(item.itemKey)
146+ var appeared by remember(item.itemKey) { mutableStateOf(! shouldAnimate) }
130147 LaunchedEffect (Unit ) { if (! appeared) appeared = true }
131148 val insertionAlphaSpec = spring<Float >(dampingRatio = 0.86f , stiffness = 80f )
132149 val insertionScaleSpec = spring<Float >(dampingRatio = 0.73f , stiffness = 300f )
@@ -154,13 +171,13 @@ internal fun MessageList(
154171
155172 Box (
156173 modifier = Modifier
157- .animateItem(fadeInSpec = null , fadeOutSpec = null )
158174 .padding(bottom = bottomSpacing),
159175 ) {
160176 when (item) {
161177 is ChatListItem .DateSeparator -> Box (insertionModifier) {
162178 DateSeparatorRow (item.timestamp)
163179 }
180+
164181 is ChatListItem .ContentBubble -> {
165182 val effectiveStatus = effectiveReceiptStatus(item, otherReadPointer)
166183 // Track whether this item was ever seen as SENDING so we
@@ -202,10 +219,40 @@ internal fun MessageList(
202219 }
203220 }
204221
205- // Chat start shows contact info container (only after messages have loaded)
206- if (messages.itemCount >= 0 && messages.loadState.refresh is LoadState .NotLoading ) {
222+ // Show trailing separator and contact info once messages are available
223+ // (from Room cache) or after the refresh settles (for empty conversations).
224+ // This prevents these items from being the only content before messages
225+ // load, which would cause the list to start at the wrong scroll position.
226+ if (messages.itemCount > 0 || refreshSettled) {
227+ // Trailing date separator for the oldest loaded message.
228+ // This replaces the `after == null` boundary from insertSeparators
229+ // which Paging 3 defers until endOfPaginationReached, causing a
230+ // visible frame delay where the message appears without its separator.
231+ if (messages.itemCount > 0 ) {
232+ val oldest = messages.peek(messages.itemCount - 1 )
233+ val oldestTimestamp = when (oldest) {
234+ is ChatListItem .ContentBubble -> oldest.timestamp
235+ is ChatListItem .DateSeparator -> null // already a separator
236+ null -> null
237+ }
238+ if (oldestTimestamp != null ) {
239+ item(key = " trailing-date-${oldestTimestamp.epochSeconds} " ) {
240+ Box (
241+ modifier = Modifier .padding(bottom = CodeTheme .dimens.grid.x2),
242+ ) {
243+ DateSeparatorRow (oldestTimestamp)
244+ }
245+ }
246+ }
247+ }
248+
249+ // Chat start shows contact info container
207250 item {
208- Box (modifier = Modifier .fillParentMaxWidth(), contentAlignment = Alignment .Center ) {
251+ Box (
252+ modifier = Modifier
253+ .fillParentMaxWidth(),
254+ contentAlignment = Alignment .Center ,
255+ ) {
209256 ContactInfoContainer (
210257 contact = state.chattingWith,
211258 modifier = Modifier
@@ -238,6 +285,23 @@ internal fun MessageList(
238285 }
239286 }
240287 }
288+
289+ // opts out of the list maintaining
290+ // scroll position when adding elements before the first item
291+ // we are checking first visible item index to ensure
292+ // the list doesn't shift when viewing scroll back
293+ Snapshot .withoutReadObservation {
294+ if (! hasLoaded) {
295+ // During initial load, always pin to index 0 (newest message)
296+ // to prevent the list from starting at the ContactInfoContainer
297+ listState.requestScrollToItem(0 , 0 )
298+ } else if (listState.firstVisibleItemIndex == 0 ) {
299+ listState.requestScrollToItem(
300+ index = listState.firstVisibleItemIndex,
301+ scrollOffset = listState.firstVisibleItemScrollOffset
302+ )
303+ }
304+ }
241305}
242306
243307@Composable
0 commit comments