From 14aa37d42aec564b9523b4ee8c25df7fc066e0c2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 29 Jun 2026 10:54:50 -0400 Subject: [PATCH] feat: add ChatAction sealed interface with CompositionLocal Threading action lambdas through MessageList -> ContentBubble -> CashBubble (and deeper as more bubble types gain interactions) is verbose. A CompositionLocal matches the project established pattern (LocalVibrator, LocalExchange, LocalCodeNavigator, etc.) and avoids lambda-threading entirely. Introduce ChatAction, ChatActionHandler, and LocalChatActionHandler in the shared chat-ui module. MessageList accepts onAction and provides it via CompositionLocalProvider so nested composables consume it from the local. Cash bubbles are now clickable, dispatching ViewToken to navigate to the token info screen. Signed-off-by: Brandon McAnsh --- .../app/messenger/internal/ChatViewModel.kt | 1 + .../internal/screens/MessengerScreen.kt | 35 +- .../screens/components/MessageList.kt | 430 +++++++++--------- .../com/flipcash/shared/chat/ui/ChatAction.kt | 15 + .../flipcash/shared/chat/ui/MessageBubble.kt | 12 + 5 files changed, 268 insertions(+), 225 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAction.kt 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 1476998d3..855cbf3ef 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 @@ -42,6 +42,7 @@ import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Limits import com.getcode.opencode.model.financial.SendLimit import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.Mint import com.getcode.solana.keys.PublicKey import com.getcode.util.resources.ResourceHelper import com.getcode.utils.trace diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 0829eb398..1d72d8cda 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -43,9 +43,11 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.compose.collectAsLazyPagingItems import com.flipcash.app.contacts.ui.ContactAvatar +import com.flipcash.app.core.AppRoute import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.screens.components.MessageList +import com.flipcash.shared.chat.ui.ChatAction import com.flipcash.features.messenger.R import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.LocalCodeNavigator @@ -99,19 +101,30 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { messages = messages, separatorConfig = state.separatorConfig, otherReadPointer = otherReadPointer, - onAdvanceReadPointer = { messageId -> - viewModel.dispatchEvent(ChatViewModel.Event.AdvanceReadPointer(messageId)) - }, - onRetryMessage = { bubble -> - keyboard.hideIfVisible { - viewModel.dispatchEvent( - ChatViewModel.Event.RetryMessage(bubble.pendingClientIdHex, bubble.content) - ) + onAction = { action -> + when (action) { + is ChatAction.AdvanceReadPointer -> { + viewModel.dispatchEvent(ChatViewModel.Event.AdvanceReadPointer(action.messageId)) + } + ChatAction.RefreshContact -> { + viewModel.dispatchEvent(ChatViewModel.Event.RefreshContact) + } + is ChatAction.RetryMessage -> { + keyboard.hideIfVisible { + viewModel.dispatchEvent( + ChatViewModel.Event.RetryMessage(action.bubble.pendingClientIdHex, action.bubble.content) + ) + } + } + is ChatAction.ViewToken -> { + keyboard.hideIfVisible { + viewModel.dispatchEvent( + ChatViewModel.Event.OpenScreen(AppRoute.Token.Info(action.mint)) + ) + } + } } }, - onRefreshContact = { - viewModel.dispatchEvent(ChatViewModel.Event.RefreshContact) - }, ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 5cf28c1bb..f78bad633 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -35,8 +35,12 @@ import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemKey import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.services.models.chat.MessagePointer +import androidx.compose.runtime.CompositionLocalProvider +import com.flipcash.shared.chat.ui.ChatAction +import com.flipcash.shared.chat.ui.ChatActionHandler import com.flipcash.shared.chat.ui.ChatListItem import com.flipcash.shared.chat.ui.ContentBubble +import com.flipcash.shared.chat.ui.LocalChatActionHandler import com.flipcash.shared.chat.ui.ReceiptStatus import com.flipcash.shared.chat.ui.SeparatorConfig import com.flipcash.shared.chat.ui.bubblePositionOf @@ -51,7 +55,6 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapNotNull - @Composable internal fun MessageList( modifier: Modifier = Modifier, @@ -60,254 +63,253 @@ internal fun MessageList( messages: LazyPagingItems, separatorConfig: SeparatorConfig, otherReadPointer: MessagePointer? = null, - onAdvanceReadPointer: ((Long) -> Unit)? = null, - onRetryMessage: ((ChatListItem.ContentBubble) -> Unit)? = null, - onRefreshContact: () -> Unit = {}, + onAction: ChatActionHandler, ) { val keyboard = rememberKeyboardController() val listState = rememberLazyListState() val vibrator = LocalVibrator.current - if (onAdvanceReadPointer != null) { - HandleMessageReads(listState, messages, onAdvanceReadPointer) - } + CompositionLocalProvider(LocalChatActionHandler provides onAction) { + HandleMessageReads(listState, messages) - // Haptic feedback when a new incoming message arrives - // Track the newest message key — only fire when it changes to an incoming message - var lastNewestKey by remember { mutableStateOf(null) } - LaunchedEffect(messages) { - snapshotFlow { - if (messages.itemCount == 0) null else messages.peek(0) - } - .filterNotNull() - .mapNotNull { it as? ChatListItem.ContentBubble } - .distinctUntilChanged { old, new -> old.itemKey == new.itemKey } - .collectLatest { newest -> - val prevKey = lastNewestKey - lastNewestKey = newest.itemKey - if (prevKey != null && !newest.isFromSelf) { - vibrator.tick() - } + // Haptic feedback when a new incoming message arrives + // Track the newest message key — only fire when it changes to an incoming message + var lastNewestKey by remember { mutableStateOf(null) } + LaunchedEffect(messages) { + snapshotFlow { + if (messages.itemCount == 0) null else messages.peek(0) } - } - - // Gate insertion animations: only animate items that arrive after the initial page load - val initialLoadComplete by remember { - derivedStateOf { - messages.loadState.refresh is LoadState.NotLoading && messages.itemCount > 0 + .filterNotNull() + .mapNotNull { it as? ChatListItem.ContentBubble } + .distinctUntilChanged { old, new -> old.itemKey == new.itemKey } + .collectLatest { newest -> + val prevKey = lastNewestKey + lastNewestKey = newest.itemKey + if (prevKey != null && !newest.isFromSelf) { + vibrator.tick() + } + } } - } - var hasLoaded by remember { mutableStateOf(false) } - LaunchedEffect(initialLoadComplete) { - if (initialLoadComplete) hasLoaded = true - } - // Keys that have already played their insertion animation — persists - // across item disposal so scrolling away and back doesn't replay. - val animatedKeys = remember { mutableSetOf() } - - // Track when the initial Paging refresh has truly completed (Loading → NotLoading). - // This avoids showing the ContactInfoContainer before messages arrive, - // which would cause the list to start scrolled to the wrong position. - var refreshSettled by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - snapshotFlow { messages.loadState.refresh } - .dropWhile { it !is LoadState.Loading } - .first { it !is LoadState.Loading } - refreshSettled = true - } - LazyColumn( - modifier = modifier - .sheetResignmentBehavior(listState) - .pointerInput(Unit) { - detectTapGestures { keyboard.hide() } - }, - state = listState, - reverseLayout = true, - contentPadding = PaddingValues( - top = CodeTheme.dimens.inset + contentPadding.calculateTopPadding(), - bottom = CodeTheme.dimens.grid.x2 + contentPadding.calculateBottomPadding(), - start = CodeTheme.dimens.inset, - end = CodeTheme.dimens.inset, - ), - verticalArrangement = Arrangement.Top, - ) { - items( - count = messages.itemCount, - key = messages.itemKey { it.itemKey } - ) { index -> - val item = messages[index] ?: return@items - val bottomSpacing = bottomSpacingFor(index, item, messages, separatorConfig) - - val isOutgoing = (item as? ChatListItem.ContentBubble)?.isFromSelf ?: false - - // Message insertion animation — scale from 0.95 + opacity with edge anchor. - // Only animate genuinely new messages (index 0 after initial load). - val shouldAnimate = index == 0 && hasLoaded && item.itemKey !in animatedKeys - if (shouldAnimate) animatedKeys.add(item.itemKey) - var appeared by remember(item.itemKey) { mutableStateOf(!shouldAnimate) } - LaunchedEffect(Unit) { if (!appeared) appeared = true } - val insertionAlpha by animateFloatAsState( - targetValue = if (appeared) 1f else 0f, - animationSpec = ChatAnimations.insertion, - label = "insertAlpha", - ) - val insertionScale by animateFloatAsState( - targetValue = if (appeared) 1f else 0.95f, - animationSpec = ChatAnimations.insertion, - label = "insertScale", - ) - - val insertionModifier = Modifier.graphicsLayer { - alpha = insertionAlpha - scaleX = insertionScale - scaleY = insertionScale - transformOrigin = if (isOutgoing) { - TransformOrigin(1f, 0.5f) // anchor trailing - } else { - TransformOrigin(0f, 0.5f) // anchor leading - } + // Gate insertion animations: only animate items that arrive after the initial page load + val initialLoadComplete by remember { + derivedStateOf { + messages.loadState.refresh is LoadState.NotLoading && messages.itemCount > 0 } + } + var hasLoaded by remember { mutableStateOf(false) } + LaunchedEffect(initialLoadComplete) { + if (initialLoadComplete) hasLoaded = true + } + // Keys that have already played their insertion animation — persists + // across item disposal so scrolling away and back doesn't replay. + val animatedKeys = remember { mutableSetOf() } + + // Track when the initial Paging refresh has truly completed (Loading → NotLoading). + // This avoids showing the ContactInfoContainer before messages arrive, + // which would cause the list to start scrolled to the wrong position. + var refreshSettled by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + snapshotFlow { messages.loadState.refresh } + .dropWhile { it !is LoadState.Loading } + .first { it !is LoadState.Loading } + refreshSettled = true + } - Box( - modifier = Modifier - .padding(bottom = bottomSpacing), - ) { - when (item) { - is ChatListItem.DateSeparator -> Box(insertionModifier) { - DateSeparatorRow(item.timestamp) + LazyColumn( + modifier = modifier + .sheetResignmentBehavior(listState) + .pointerInput(Unit) { + detectTapGestures { keyboard.hide() } + }, + state = listState, + reverseLayout = true, + contentPadding = PaddingValues( + top = CodeTheme.dimens.inset + contentPadding.calculateTopPadding(), + bottom = CodeTheme.dimens.grid.x2 + contentPadding.calculateBottomPadding(), + start = CodeTheme.dimens.inset, + end = CodeTheme.dimens.inset, + ), + verticalArrangement = Arrangement.Top, + ) { + items( + count = messages.itemCount, + key = messages.itemKey { it.itemKey } + ) { index -> + val item = messages[index] ?: return@items + val bottomSpacing = bottomSpacingFor(index, item, messages, separatorConfig) + + val isOutgoing = (item as? ChatListItem.ContentBubble)?.isFromSelf ?: false + + // Message insertion animation — scale from 0.95 + opacity with edge anchor. + // Only animate genuinely new messages (index 0 after initial load). + val shouldAnimate = index == 0 && hasLoaded && item.itemKey !in animatedKeys + if (shouldAnimate) animatedKeys.add(item.itemKey) + var appeared by remember(item.itemKey) { mutableStateOf(!shouldAnimate) } + LaunchedEffect(Unit) { if (!appeared) appeared = true } + val insertionAlpha by animateFloatAsState( + targetValue = if (appeared) 1f else 0f, + animationSpec = ChatAnimations.insertion, + label = "insertAlpha", + ) + val insertionScale by animateFloatAsState( + targetValue = if (appeared) 1f else 0.95f, + animationSpec = ChatAnimations.insertion, + label = "insertScale", + ) + + val insertionModifier = Modifier.graphicsLayer { + alpha = insertionAlpha + scaleX = insertionScale + scaleY = insertionScale + transformOrigin = if (isOutgoing) { + TransformOrigin(1f, 0.5f) // anchor trailing + } else { + TransformOrigin(0f, 0.5f) // anchor leading } + } - is ChatListItem.ContentBubble -> { - val effectiveStatus = effectiveReceiptStatus(item, otherReadPointer) - // Track whether this item was ever seen as SENDING so we - // can animate the receipt label entrance on the - // SENDING→SENT transition. This remember persists across - // recompositions of the same item (keyed by LazyColumn), - // surviving the status change that gates the label. - var wasSending by remember { mutableStateOf(false) } - if (item.receiptStatus == ReceiptStatus.SENDING) { - wasSending = true + Box( + modifier = Modifier + .padding(bottom = bottomSpacing), + ) { + when (item) { + is ChatListItem.DateSeparator -> Box(insertionModifier) { + DateSeparatorRow(item.timestamp) } - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start, - ) { - Box(insertionModifier) { - ContentBubble( - item = item, - position = bubblePositionOf( - index, - item, - messages, - separatorConfig - ), - ) + + is ChatListItem.ContentBubble -> { + val effectiveStatus = effectiveReceiptStatus(item, otherReadPointer) + // Track whether this item was ever seen as SENDING so we + // can animate the receipt label entrance on the + // SENDING→SENT transition. This remember persists across + // recompositions of the same item (keyed by LazyColumn), + // surviving the status change that gates the label. + var wasSending by remember { mutableStateOf(false) } + if (item.receiptStatus == ReceiptStatus.SENDING) { + wasSending = true } - val showReceipt = - shouldShowReceiptLabel(index, item, messages, otherReadPointer) - AnimatedVisibility( - visible = showReceipt && effectiveStatus != null, - enter = EnterTransition.None, - exit = ChatAnimations.receiptExit, + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start, ) { - if (effectiveStatus != null) { - ReceiptLabel( - status = effectiveStatus, - readPointer = otherReadPointer, - animateEntrance = wasSending, - onRetryFailed = if (effectiveStatus == ReceiptStatus.FAILED) { - { onRetryMessage?.invoke(item) } - } else null, + Box(insertionModifier) { + ContentBubble( + item = item, + position = bubblePositionOf( + index, + item, + messages, + separatorConfig + ), ) } + val showReceipt = + shouldShowReceiptLabel(index, item, messages, otherReadPointer) + AnimatedVisibility( + visible = showReceipt && effectiveStatus != null, + enter = EnterTransition.None, + exit = ChatAnimations.receiptExit, + ) { + if (effectiveStatus != null) { + ReceiptLabel( + status = effectiveStatus, + readPointer = otherReadPointer, + animateEntrance = wasSending, + onRetryFailed = if (effectiveStatus == ReceiptStatus.FAILED) { + { onAction(ChatAction.RetryMessage(item)) } + } else null, + ) + } + } } } } } } - } - // Show trailing separator and contact info once messages are available - // (from Room cache) or after the refresh settles (for empty conversations). - // This prevents these items from being the only content before messages - // load, which would cause the list to start at the wrong scroll position. - if (messages.itemCount > 0 || refreshSettled) { - // Trailing date separator for the oldest loaded message. - // This replaces the `after == null` boundary from insertSeparators - // which Paging 3 defers until endOfPaginationReached, causing a - // visible frame delay where the message appears without its separator. - if (messages.itemCount > 0) { - val oldest = messages.peek(messages.itemCount - 1) - val oldestTimestamp = when (oldest) { - is ChatListItem.ContentBubble -> oldest.timestamp - is ChatListItem.DateSeparator -> null // already a separator - null -> null - } - if (oldestTimestamp != null) { - item(key = "trailing-date-${oldestTimestamp.epochSeconds}") { - Box( - modifier = Modifier.padding(bottom = CodeTheme.dimens.grid.x2), - ) { - DateSeparatorRow(oldestTimestamp) + // Show trailing separator and contact info once messages are available + // (from Room cache) or after the refresh settles (for empty conversations). + // This prevents these items from being the only content before messages + // load, which would cause the list to start at the wrong scroll position. + if (messages.itemCount > 0 || refreshSettled) { + // Trailing date separator for the oldest loaded message. + // This replaces the `after == null` boundary from insertSeparators + // which Paging 3 defers until endOfPaginationReached, causing a + // visible frame delay where the message appears without its separator. + if (messages.itemCount > 0) { + val oldest = messages.peek(messages.itemCount - 1) + val oldestTimestamp = when (oldest) { + is ChatListItem.ContentBubble -> oldest.timestamp + is ChatListItem.DateSeparator -> null // already a separator + null -> null + } + if (oldestTimestamp != null) { + item(key = "trailing-date-${oldestTimestamp.epochSeconds}") { + Box( + modifier = Modifier.padding(bottom = CodeTheme.dimens.grid.x2), + ) { + DateSeparatorRow(oldestTimestamp) + } } } } - } - // Chat start shows contact info container - item { - Box( - modifier = Modifier - .fillParentMaxWidth(), - contentAlignment = Alignment.Center, - ) { - ContactInfoContainer( - contact = state.chattingWith, + // Chat start shows contact info container + item { + Box( modifier = Modifier - .padding(horizontal = CodeTheme.dimens.grid.x12), - onRefreshContact = onRefreshContact, - ) + .fillParentMaxWidth(), + contentAlignment = Alignment.Center, + ) { + ContactInfoContainer( + contact = state.chattingWith, + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.grid.x12), + onRefreshContact = { onAction(ChatAction.RefreshContact) }, + ) + } } } } - } - // Scroll to bottom when the newest message changes (sent or received) - LaunchedEffect(listState, messages) { - snapshotFlow { - if (messages.itemCount == 0) null - else (messages.peek(0) as? ChatListItem.ContentBubble)?.itemKey - } - .filterNotNull() - .distinctUntilChanged() - .collectLatest { - // Always scroll for own messages; only near-bottom for incoming - val nearBottom = listState.firstVisibleItemIndex <= 5 - val newest = messages.peek(0) as? ChatListItem.ContentBubble - if (newest?.isFromSelf == true || nearBottom) { - if (nearBottom) { - listState.animateScrollToItem(0) - } else { - listState.scrollToItem(0) + // Scroll to bottom when the newest message changes (sent or received) + LaunchedEffect(listState, messages) { + snapshotFlow { + if (messages.itemCount == 0) null + else (messages.peek(0) as? ChatListItem.ContentBubble)?.itemKey + } + .filterNotNull() + .distinctUntilChanged() + .collectLatest { + // Always scroll for own messages; only near-bottom for incoming + val nearBottom = listState.firstVisibleItemIndex <= 5 + val newest = messages.peek(0) as? ChatListItem.ContentBubble + if (newest?.isFromSelf == true || nearBottom) { + if (nearBottom) { + listState.animateScrollToItem(0) + } else { + listState.scrollToItem(0) + } } } - } - } + } - // Opt out of the list maintaining scroll position when adding - // elements before the first item. Only needed during initial load - // (to prevent starting at the ContactInfoContainer) and when - // scrolled back (to prevent shifting when pagination prepends). - // When at index 0, we do NOT call requestScrollToItem — doing so - // forces an instant reposition that causes a single-frame jitter - // when new messages are inserted. Instead, animateScrollToItem in - // the LaunchedEffect above handles smooth scrolling to new items. - Snapshot.withoutReadObservation { - if (!hasLoaded) { - listState.requestScrollToItem(0, 0) + // Opt out of the list maintaining scroll position when adding + // elements before the first item. Only needed during initial load + // (to prevent starting at the ContactInfoContainer) and when + // scrolled back (to prevent shifting when pagination prepends). + // When at index 0, we do NOT call requestScrollToItem — doing so + // forces an instant reposition that causes a single-frame jitter + // when new messages are inserted. Instead, animateScrollToItem in + // the LaunchedEffect above handles smooth scrolling to new items. + Snapshot.withoutReadObservation { + if (!hasLoaded) { + listState.requestScrollToItem(0, 0) + } } - } + + } // CompositionLocalProvider } @Composable @@ -345,8 +347,8 @@ private fun bottomSpacingFor( private fun HandleMessageReads( listState: LazyListState, messages: LazyPagingItems, - onAdvanceReadPointer: (Long) -> Unit, ) { + val actionHandler = LocalChatActionHandler.current var lastAdvanced by remember { mutableLongStateOf(0L) } LaunchedEffect(listState, messages) { @@ -371,7 +373,7 @@ private fun HandleMessageReads( .collectLatest { messageId -> if (messageId > lastAdvanced) { lastAdvanced = messageId - onAdvanceReadPointer(messageId) + actionHandler(ChatAction.AdvanceReadPointer(messageId)) } } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAction.kt new file mode 100644 index 000000000..429f68586 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAction.kt @@ -0,0 +1,15 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.runtime.staticCompositionLocalOf +import com.getcode.solana.keys.Mint + +sealed interface ChatAction { + data class RetryMessage(val bubble: ChatListItem.ContentBubble) : ChatAction + data class AdvanceReadPointer(val messageId: Long) : ChatAction + object RefreshContact : ChatAction + data class ViewToken(val mint: Mint) : ChatAction +} + +typealias ChatActionHandler = (ChatAction) -> Unit + +val LocalChatActionHandler = staticCompositionLocalOf { {} } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 13112f693..b91a57c96 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -49,6 +50,7 @@ import com.getcode.opencode.compose.LocalExchange import com.getcode.opencode.model.financial.Fiat import com.getcode.theme.CodeTheme import com.getcode.ui.components.PriceWithFlag +import com.getcode.ui.components.chat.messagecontents.MessageContentActionHandler import com.getcode.ui.core.addIf import com.getcode.util.resources.R @@ -63,6 +65,7 @@ fun ContentBubble( position: BubblePosition, modifier: Modifier = Modifier, ) { + val actionHandler = LocalChatActionHandler.current BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val bubbleMaxWidth = when (item.content) { is MessageContent.Text -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION @@ -94,6 +97,9 @@ fun ContentBubble( isFromSelf = item.isFromSelf, position = position, maxWidth = bubbleMaxWidth, + onClick = { + actionHandler(ChatAction.ViewToken(content.mint)) + } ) // TODO @@ -143,6 +149,7 @@ private fun CashBubble( isFromSelf: Boolean, position: BubblePosition, maxWidth: Dp, + onClick: () -> Unit = { }, modifier: Modifier = Modifier, ) { Bubble( @@ -150,6 +157,7 @@ private fun CashBubble( position = position, minWidth = maxWidth, maxWidth = maxWidth, + onClick = onClick, modifier = modifier ) { val exchange = LocalExchange.current @@ -238,6 +246,7 @@ private fun Bubble( maxWidth: Dp, modifier: Modifier = Modifier, minWidth: Dp = 0.dp, + onClick: (() -> Unit)? = null, content: @Composable BoxScope.() -> Unit, ) { val bubble = if (isFromSelf) { @@ -254,6 +263,9 @@ private fun Bubble( Modifier.border(1.dp, bubble.border, shape) } .background(bubble.background) + .addIf(onClick != null) { + Modifier.clip(shape).clickable { onClick?.invoke() } + } .padding(horizontal = 16.dp, vertical = 10.dp), ) { content()