From 8926f1eba0375973e8eddcc3339ac7a30b37532f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 28 Jul 2026 19:28:59 -0400 Subject: [PATCH] fix(tips): condense send button on open without the white pill flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tip DMs open via ByChatId with a null participant, so the send button read the chat as a normal DM and rendered the white expanded "Send $" pill, then eased white -> transparent once the tip profile resolved over the network — a visible flash on every tip-chat open. Resolve the chat kind from the fast local contact lookup instead of the network profile: carry a real ChatType on ChatViewModel.State (UNKNOWN -> CONTACT_DM / TIP_DM), set the moment the chat opens. The send button and bottom bar key off chatType, snap (not ease) the first color commit, and the bar only reveals once the kind is known — so a tip chat appears already condensed with no flash and no delay. Also: the condensed symbol uses textLarge and animates its size between states, and the tip-user indicator drops the pill. --- .../app/messenger/internal/ChatViewModel.kt | 43 ++++++++++------- .../screens/components/ChatBottomBar.kt | 15 +++++- .../components/ContactInfoContainer.kt | 3 +- .../screens/components/SendCashButton.kt | 48 ++++++++++++++++--- 4 files changed, 84 insertions(+), 25 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 134ce4951..7d2d542ab 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -122,6 +122,12 @@ internal class ChatViewModel @Inject constructor( val separatorConfig: SeparatorConfig= SeparatorConfig.Continuous(), val chatId: ChatId? = null, val participant: ChatParticipant? = null, + // The kind of DM this conversation is, resolved from the fast local contact lookup ahead of + // the participant's server profile (which resolves over the network for tip DMs). Starts + // UNKNOWN and settles to CONTACT_DM / TIP_DM as soon as the chat opens; the send button and + // bottom bar read it to render the correct (condensed vs expanded) presentation immediately + // instead of flashing the expanded white pill while a tip profile loads. + val chatType: ChatType = ChatType.UNKNOWN, val chatInputState: TextFieldState = TextFieldState(), val typists: Set = emptySet(), val resolveState: ResolveState = ResolveState.Pending, @@ -145,6 +151,7 @@ internal class ChatViewModel @Inject constructor( data class OnChatOpened(val identifier: ChatIdentifier) : Event data class OnContactFound(val contact: DeviceContact): Event data class OnTipUserResolved(val userId: ID, val profile: UserProfile): Event + data object OnTipDmDetected : Event data class OnCurrencySymbolUpdated(val symbol: String): Event data object RefreshContact : Event data class ChatFound(val chatId: ChatId) : Event @@ -264,14 +271,6 @@ internal class ChatViewModel @Inject constructor( .map { it.participant is ChatParticipant.TipUser } .distinctUntilChanged() - /** The [ChatType] backing this conversation, derived from the resolved participant. */ - private val ChatParticipant?.chatType: ChatType - get() = when (this) { - is ChatParticipant.TipUser -> ChatType.TIP_DM - is ChatParticipant.Contact -> ChatType.CONTACT_DM - null -> ChatType.UNKNOWN - } - private val amountStyleFlow by lazy { isTipFlow .map { amountStyle(isTip = it) } @@ -362,11 +361,14 @@ internal class ChatViewModel @Inject constructor( if (contact != null) { dispatchEvent(Event.OnContactFound(contact)) } else { - // No device contact backs this chat — it's a tip DM. Warm the member - // store (fetch + persist if nothing is cached) so the reactive - // tip-identity collector can resolve the counterparty from their server - // profile. Identity is set reactively (see initChatHandlers), not here, - // so it can't be missed by a fast tap on "Send $". + // No device contact backs this chat — it's a tip DM. Mark it immediately + // (this lookup is local) so the send button renders condensed without + // waiting on the profile below, then warm the member store (fetch + + // persist if nothing is cached) so the reactive tip-identity collector + // can resolve the counterparty from their server profile. Identity is set + // reactively (see initChatHandlers), not here, so it can't be missed by a + // fast tap on "Send $". + dispatchEvent(Event.OnTipDmDetected) viewModelScope.launch { chatCoordinator.getOtherMember(identifier.chatId) } } } @@ -558,7 +560,7 @@ internal class ChatViewModel @Inject constructor( val textToSend = stateFlow.value.chatInputState.text.toString() val chatId = stateFlow.value.chatId ?: return@onEach if (textToSend.isBlank()) return@onEach - val chatType = stateFlow.value.participant.chatType + val chatType = stateFlow.value.chatType stateFlow.value.chatInputState.setTextAndPlaceCursorAtEnd("") @@ -831,13 +833,21 @@ internal class ChatViewModel @Inject constructor( when (event) { is Event.OnChatOpened -> { state -> when (val id = event.identifier) { - is ChatIdentifier.ByContact -> state.copy(participant = ChatParticipant.Contact(id.contact)) + is ChatIdentifier.ByContact -> + state.copy( + participant = ChatParticipant.Contact(id.contact), + chatType = ChatType.CONTACT_DM, + ) is ChatIdentifier.ByChatId -> state } } is Event.OnContactFound -> { state -> - state.copy(participant = ChatParticipant.Contact(event.contact)) + state.copy( + participant = ChatParticipant.Contact(event.contact), + chatType = ChatType.CONTACT_DM, + ) } + Event.OnTipDmDetected -> { state -> state.copy(chatType = ChatType.TIP_DM) } is Event.OnTipUserResolved -> { state -> // A device contact, once matched, wins over the server profile (it carries the // phone number and the user's own naming). Otherwise this is a tip DM: adopt the @@ -846,6 +856,7 @@ internal class ChatViewModel @Inject constructor( if (state.participant is ChatParticipant.Contact) state else state.copy( participant = ChatParticipant.TipUser(event.userId, event.profile), + chatType = ChatType.TIP_DM, resolveState = ResolveState.Resolved, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index 8be4df7b2..68cb4fb7a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.screens.ChatAnimations +import com.flipcash.services.models.chat.ChatType import com.flipcash.features.messenger.R import com.getcode.theme.CodeTheme import com.getcode.ui.components.chat.ChatInput @@ -124,7 +125,19 @@ internal fun UserControlBottomBar( // scaled down to the pill + input box. Hold the bar invisible (but measured, so // the message list keeps correct padding) until resolved, then reveal the final // layout directly — no visible full-width state, no resize. - .alpha(if (state.typingConstraints.resolved) 1f else 0f), + // + // The chat kind starts UNKNOWN and, for tip DMs, SendCashButton would otherwise + // read a not-yet-resolved chat as a non-tip chat and show the white expanded pill + // before condensing — a visible flash. Wait until the kind is known (chatType is + // CONTACT_DM or TIP_DM) so the bar reveals already in its final presentation. + // chatType resolves from a local contact lookup, not the network profile, so this + // adds no perceptible delay; a tip chat whose identity never resolves flips to the + // deactivated bar instead, so this can't hide it forever. + .alpha( + if (state.typingConstraints.resolved && + state.chatType != ChatType.UNKNOWN + ) 1f else 0f + ), targetState = state.typingConstraints.enabled, // The layout only ever changes on the initial async resolution, which is hidden by // the alpha gate above, so snap rather than crossfade. The SendCashButton's own diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt index 89ceb320c..bb55f4ec4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt @@ -123,8 +123,7 @@ private fun Indicator( onClick = onClick ) - is ChatParticipant.TipUser -> TipPill(modifier = modifier) - null -> Spacer(modifier = modifier.fillMaxWidth()) + else -> Spacer(modifier = modifier.fillMaxWidth()) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt index 6a13cd757..4ea96d4d1 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt @@ -2,7 +2,10 @@ package com.flipcash.app.messenger.internal.screens.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.expandHorizontally @@ -19,8 +22,11 @@ import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text 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.clip @@ -28,8 +34,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import com.flipcash.app.messenger.internal.ChatParticipant +import androidx.compose.ui.unit.sp import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.services.models.chat.ChatType import com.flipcash.features.messenger.R import com.getcode.theme.CodeTheme import com.getcode.ui.core.addIf @@ -47,13 +54,22 @@ internal fun RowScope.SendCashButton( ) { // Tip chats always use the minimized (dark, symbol-only) button. The normal send flow keeps the // expanded "Send $" presentation and only collapses to the symbol once the user starts typing. - val isTipChat = state.participant is ChatParticipant.TipUser + // chatType resolves from the fast local contact lookup, so a tip DM condenses immediately rather + // than waiting on the server profile. + val isTipChat = state.chatType == ChatType.TIP_DM val isTyping = isTipChat || state.chatInputState.text.isNotEmpty() val canType = state.typingConstraints.enabled - // Colors ease slowly and independently of the width/label so the fill change reads as one - // calm transition instead of snapping with the resize. - val colorSpec = tween(durationMillis = 350) + // Colors ease slowly and independently of the width/label so the fill change reads as one calm + // transition instead of snapping with the resize — but NOT on the first settle. A tip chat opens + // before its kind is known, briefly reading as a non-tip chat (white); easing that initial commit + // would fade white→transparent in view. Snap until the kind resolves and the button commits its + // first appearance, then ease subsequent typing toggles. + val kindResolved = state.chatType != ChatType.UNKNOWN + var hasSettled by remember { mutableStateOf(false) } + LaunchedEffect(kindResolved) { if (kindResolved) hasSettled = true } + val colorSpec: AnimationSpec = + if (hasSettled) tween(durationMillis = 350) else snap() val backgroundColor by animateColorAsState( targetValue = if (isTyping) Color.Transparent else Color.White, animationSpec = colorSpec, @@ -77,6 +93,23 @@ internal fun RowScope.SendCashButton( stiffness = Spring.StiffnessMediumLow, ) + // The symbol grows from textMedium to textLarge as the button condenses. Animating the + // fontUnit (rather than swapping styles) lets it ease alongside the width spring so the + // resize and the type scale read as one motion. Sizes come from the theme so they track + // any typography change. + val symbolFontSize by animateFloatAsState( + targetValue = if (isTyping) { + 22.sp.value + } else { + CodeTheme.typography.textMedium.fontSize.value + }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + label = "send button symbol size", + ) + val shape = if (canType) CodeTheme.shapes.medium else CodeTheme.shapes.small Row( @@ -107,6 +140,7 @@ internal fun RowScope.SendCashButton( val prefix = remember(fullText, state.cashSymbol) { fullText.removeSuffix(state.cashSymbol) } + AnimatedVisibility( visible = !isTyping, enter = fadeIn() + expandHorizontally(widthSpec, expandFrom = Alignment.Start), @@ -123,7 +157,9 @@ internal fun RowScope.SendCashButton( Text( text = state.cashSymbol, color = contentColor, - style = CodeTheme.typography.textMedium, + // Base style stays textMedium; the animated fontSize carries it up to textLarge in + // the condensed state so the symbol matches the "Send " prefix when expanded. + style = CodeTheme.typography.textMedium.copy(fontSize = symbolFontSize.sp), maxLines = 1, softWrap = false, )