Skip to content

Commit 770820d

Browse files
authored
chore(chat): improve chat open transitions and performance (#955)
Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 935448c commit 770820d

3 files changed

Lines changed: 76 additions & 13 deletions

File tree

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ internal class ChatViewModel @Inject constructor(
107107
}
108108

109109
data class State(
110+
val separatorConfig: SeparatorConfig= SeparatorConfig.Continuous(),
110111
val chatId: ChatId? = null,
111112
val chattingWith: DeviceContact? = null,
112113
val userState: UserState = UserState.Reading,
@@ -161,8 +162,6 @@ internal class ChatViewModel @Inject constructor(
161162
data class ChatDeactivated(val isReadOnly: Boolean) : Event
162163
}
163164

164-
private val separatorConfig = SeparatorConfig.Continuous()
165-
166165
@OptIn(ExperimentalCoroutinesApi::class)
167166
private val messageStream = stateFlow.mapNotNull { it.chatId }
168167
.distinctUntilChanged()
@@ -205,8 +204,8 @@ internal class ChatViewModel @Inject constructor(
205204
)
206205
}
207206
}.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? ->
208-
if (before == null) return@insertSeparators null
209-
if (after == null || separatorConfig.shouldSeparate(before.timestamp, after.timestamp)) {
207+
if (before == null || after == null) return@insertSeparators null
208+
if (stateFlow.value.separatorConfig.shouldSeparate(before.timestamp, after.timestamp)) {
210209
ChatListItem.DateSeparator(before.timestamp)
211210
} else null
212211
}

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
9999
state = state,
100100
contentPadding = overlapPadding,
101101
messages = messages,
102-
separatorConfig = SeparatorConfig.TimeGap(),
102+
separatorConfig = state.separatorConfig,
103103
otherReadPointer = otherReadPointer,
104104
onAdvanceReadPointer = { messageId ->
105105
viewModel.dispatchEvent(ChatViewModel.Event.AdvanceReadPointer(messageId))

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@ import androidx.compose.foundation.lazy.LazyListState
1414
import androidx.compose.foundation.lazy.rememberLazyListState
1515
import androidx.compose.runtime.Composable
1616
import androidx.compose.runtime.LaunchedEffect
17+
import androidx.compose.runtime.derivedStateOf
1718
import androidx.compose.runtime.getValue
1819
import androidx.compose.runtime.mutableLongStateOf
19-
import androidx.compose.runtime.derivedStateOf
2020
import androidx.compose.runtime.mutableStateOf
2121
import androidx.compose.runtime.remember
2222
import androidx.compose.runtime.setValue
2323
import androidx.compose.runtime.snapshotFlow
24+
import androidx.compose.runtime.snapshots.Snapshot
2425
import androidx.compose.ui.Alignment
2526
import androidx.compose.ui.Modifier
2627
import androidx.compose.ui.graphics.TransformOrigin
@@ -43,9 +44,10 @@ import com.getcode.ui.utils.sheetResignmentBehavior
4344
import com.getcode.util.vibration.LocalVibrator
4445
import kotlinx.coroutines.flow.collectLatest
4546
import kotlinx.coroutines.flow.distinctUntilChanged
47+
import kotlinx.coroutines.flow.dropWhile
4648
import kotlinx.coroutines.flow.filterNotNull
49+
import kotlinx.coroutines.flow.first
4750
import 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

Comments
 (0)