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, )