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