Merge pull request #860 from permissionlesstech/feat/bubble-media-messages

feat(ui): wrap image and voice messages in bubble shells
This commit is contained in:
callebtc 2026-08-02 20:33:10 +02:00 committed by GitHub
commit 49753ccb88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 334 additions and 88 deletions

View File

@ -461,9 +461,9 @@ fun MessageItem(
}
}
// Bubble mode: text messages carry the marker inline, trailing the timestamp. Media
// rows have no inline text, so their marker stays beneath the end-aligned card.
if (bubbles && message.type != BitchatMessageType.Message &&
// Bubble mode: text and media bubbles carry the marker inline, trailing the timestamp.
// File rows have no bubble shell, so their marker stays beneath the end-aligned row.
if (bubbles && message.type == BitchatMessageType.File &&
message.isPrivate && message.sender == currentUserNickname
) {
message.deliveryStatus?.let { status ->

View File

@ -24,7 +24,6 @@ import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import androidx.compose.material3.ColorScheme
import com.bitchat.android.ui.theme.LocalBitchatPalette
import com.bitchat.android.ui.isFromSelf
import java.text.SimpleDateFormat
import androidx.compose.ui.platform.LocalContext
@ -48,8 +47,6 @@ fun AudioMessageItem(
.getInstance(context).liveMessageIDs.collectAsState()
val isLive = message.id in liveMessageIDs
val path = message.content.trim()
// Bubble mode aligns self-authored voice notes to the end side, mirroring text bubbles.
val isSelfInBubbles = bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID)
// Derive sending progress if applicable
val (overrideProgress, overrideColor) = when (val st = message.deliveryStatus) {
is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> {
@ -59,9 +56,55 @@ fun AudioMessageItem(
}
else -> null to null
}
// Bubble mode wraps the voice note in the same tinted shell as text bubbles; Matrix mode
// keeps the flat header-plus-player layout.
if (bubbles) {
MediaBubbleShell(
message = message,
currentUserNickname = currentUserNickname,
myPeerID = meshService.myPeerID,
showSender = showSender,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onLongPress = { onMessageLongPress?.invoke(message) },
modifier = modifier,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (isLive) {
androidx.compose.material3.Text(
text = "LIVE",
color = Color(0xFFFFB300),
style = androidx.compose.material3.MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(end = 8.dp)
)
}
VoiceNotePlayer(
path = path,
progressOverride = overrideProgress,
progressColor = overrideColor,
modifier = Modifier.widthIn(max = 260.dp)
)
val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered)
if (showCancel) {
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier
.size(26.dp)
.background(Color.Gray.copy(alpha = 0.6f), CircleShape)
.clickable { onCancelTransfer?.invoke(message) },
contentAlignment = Alignment.Center
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(16.dp))
}
}
}
}
return
}
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = if (isSelfInBubbles) Alignment.End else Alignment.Start,
horizontalAlignment = Alignment.Start,
) {
// Header: nickname + timestamp line above the audio note, identical styling to text messages
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
@ -104,9 +147,6 @@ fun AudioMessageItem(
path = path,
progressOverride = overrideProgress,
progressColor = overrideColor,
// Self voice notes hug the end side like other self content instead of
// spanning the full row.
modifier = if (isSelfInBubbles) Modifier.widthIn(max = 300.dp) else Modifier
)
val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered)
if (showCancel) {

View File

@ -2,9 +2,11 @@ package com.bitchat.android.ui.media
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
@ -28,7 +30,6 @@ import com.bitchat.android.model.BitchatMessageType
import androidx.compose.material3.ColorScheme
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
import com.bitchat.android.ui.theme.LocalBitchatPalette
import com.bitchat.android.ui.isFromSelf
import java.text.SimpleDateFormat
@Composable
@ -49,17 +50,53 @@ fun ImageMessageItem(
) {
val palette = LocalBitchatPalette.current
val path = message.content.trim()
// Bubble mode aligns self-authored media to the end side, mirroring text bubbles; the
// corner on the speaker's side is tightened into the same subtle tail.
val isSelfInBubbles = bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID)
val imageShape = when {
!bubbles -> androidx.compose.foundation.shape.RoundedCornerShape(10.dp)
isSelfInBubbles -> androidx.compose.foundation.shape.RoundedCornerShape(10.dp, 10.dp, 3.dp, 10.dp)
else -> androidx.compose.foundation.shape.RoundedCornerShape(10.dp, 10.dp, 10.dp, 3.dp)
// Bubble mode wraps the image in the same tinted shell as text bubbles; Matrix mode keeps
// the flat header-plus-card layout.
val bubblesMode = bubbles
val imageShape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp)
val context = LocalContext.current
val bmp = remember(path) { try { android.graphics.BitmapFactory.decodeFile(path) } catch (_: Exception) { null } }
// Collect all image paths from messages for swipe navigation
val imagePaths = remember(messages) {
messages.filter { it.type == BitchatMessageType.Image }
.map { it.content.trim() }
}
val haptic = LocalHapticFeedback.current
if (bubblesMode) {
MediaBubbleShell(
message = message,
currentUserNickname = currentUserNickname,
myPeerID = meshService.myPeerID,
showSender = showSender,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onLongPress = { onMessageLongPress?.invoke(message) },
modifier = modifier,
) {
ImageMessageCard(
message = message,
currentUserNickname = currentUserNickname,
path = path,
bmp = bmp,
imagePaths = imagePaths,
imageShape = imageShape,
onImageClick = onImageClick,
onCancelTransfer = onCancelTransfer,
onLongPress = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onMessageLongPress?.invoke(message)
},
)
}
return
}
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = if (isSelfInBubbles) Alignment.End else Alignment.Start,
horizontalAlignment = Alignment.Start,
) {
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
message = message,
@ -70,7 +107,6 @@ fun ImageMessageItem(
timeFormatter = timeFormatter,
includeSender = showSender
)
val haptic = LocalHapticFeedback.current
AnnotatedClickableText(
text = headerText,
annotationTags = listOf("nickname_click"),
@ -88,78 +124,93 @@ fun ImageMessageItem(
color = colorScheme.onSurface,
)
val context = LocalContext.current
val bmp = remember(path) { try { android.graphics.BitmapFactory.decodeFile(path) } catch (_: Exception) { null } }
// Collect all image paths from messages for swipe navigation
val imagePaths = remember(messages) {
messages.filter { it.type == BitchatMessageType.Image }
.map { it.content.trim() }
}
if (bmp != null) {
val img = bmp.asImageBitmap()
val aspect = (bmp.width.toFloat() / bmp.height.toFloat()).takeIf { it.isFinite() && it > 0 } ?: 1f
val progressFraction: Float? = when (val st = message.deliveryStatus) {
is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> if (st.total > 0) st.reached.toFloat() / st.total.toFloat() else 0f
else -> null
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isSelfInBubbles) Arrangement.End else Arrangement.Start
) {
Box {
if (progressFraction != null && progressFraction < 1f && message.sender == currentUserNickname) {
// Cyberpunk block-reveal while sending
BlockRevealImage(
bitmap = img,
progress = progressFraction,
blocksX = 24,
blocksY = 16,
modifier = Modifier
.widthIn(max = 300.dp)
.aspectRatio(aspect)
.clip(imageShape)
.clickable {
val currentIndex = imagePaths.indexOf(path)
onImageClick?.invoke(path, imagePaths, currentIndex)
}
)
} else {
// Fully revealed image
Image(
bitmap = img,
contentDescription = stringResource(com.bitchat.android.R.string.cd_image),
modifier = Modifier
.widthIn(max = 300.dp)
.aspectRatio(aspect)
.clip(imageShape)
.clickable {
val currentIndex = imagePaths.indexOf(path)
onImageClick?.invoke(path, imagePaths, currentIndex)
},
contentScale = ContentScale.Fit
)
}
// Cancel button overlay during sending
val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered)
if (showCancel) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(22.dp)
.background(Color.Gray.copy(alpha = 0.6f), CircleShape)
.clickable { onCancelTransfer?.invoke(message) },
contentAlignment = Alignment.Center
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(com.bitchat.android.R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(14.dp))
}
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
ImageMessageCard(
message = message,
currentUserNickname = currentUserNickname,
path = path,
bmp = bmp,
imagePaths = imagePaths,
imageShape = imageShape,
onImageClick = onImageClick,
onCancelTransfer = onCancelTransfer,
onLongPress = null,
)
}
} else {
Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = BitchatFontFamily, color = Color.Gray)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ImageMessageCard(
message: BitchatMessage,
currentUserNickname: String,
path: String,
bmp: android.graphics.Bitmap?,
imagePaths: List<String>,
imageShape: androidx.compose.foundation.shape.RoundedCornerShape,
onImageClick: ((String, List<String>, Int) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
onLongPress: (() -> Unit)?,
) {
if (bmp == null) {
Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = BitchatFontFamily, color = Color.Gray)
return
}
val img = bmp.asImageBitmap()
val aspect = (bmp.width.toFloat() / bmp.height.toFloat()).takeIf { it.isFinite() && it > 0 } ?: 1f
val progressFraction: Float? = when (val st = message.deliveryStatus) {
is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> if (st.total > 0) st.reached.toFloat() / st.total.toFloat() else 0f
else -> null
}
Box {
val imageModifier = Modifier
.widthIn(max = 300.dp)
.aspectRatio(aspect)
.clip(imageShape)
.combinedClickable(
onClick = {
val currentIndex = imagePaths.indexOf(path)
onImageClick?.invoke(path, imagePaths, currentIndex)
},
onLongClick = { onLongPress?.invoke() },
)
if (progressFraction != null && progressFraction < 1f && message.sender == currentUserNickname) {
// Cyberpunk block-reveal while sending
BlockRevealImage(
bitmap = img,
progress = progressFraction,
blocksX = 24,
blocksY = 16,
modifier = imageModifier
)
} else {
// Fully revealed image
Image(
bitmap = img,
contentDescription = stringResource(com.bitchat.android.R.string.cd_image),
modifier = imageModifier,
contentScale = ContentScale.Fit
)
}
// Cancel button overlay during sending
val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered)
if (showCancel) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(22.dp)
.background(Color.Gray.copy(alpha = 0.6f), CircleShape)
.clickable { onCancelTransfer?.invoke(message) },
contentAlignment = Alignment.Center
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(com.bitchat.android.R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(14.dp))
}
}
}
}

View File

@ -0,0 +1,155 @@
package com.bitchat.android.ui.media
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.DeliveryStatusIcon
import com.bitchat.android.ui.formatTextMessageMetadata
import com.bitchat.android.ui.formatTextMessageSender
import com.bitchat.android.ui.isFromSelf
import com.bitchat.android.ui.peerIdentityForMessage
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.ui.theme.ChatVisualTokens
import com.bitchat.android.ui.theme.LocalBitchatPalette
import com.bitchat.android.ui.theme.MessageSenderTextStyle
import com.bitchat.android.ui.theme.colorForPeer
import java.text.SimpleDateFormat
/**
* Classic messenger shell for media messages (images, voice notes) in `ChatUiMode.Bubbles`,
* mirroring the text-bubble treatment: the author's identity colour washes the fill and
* hairline, the speaker-side corner tightens into a tail, the sender's name heads the first
* bubble of a run, and the timestamp (plus delivery ticks for own private messages) rides
* flush-right beneath the media. The shell itself is long-clickable so media bubbles always
* open the message action sheet even when no sender label is shown and the media consumes
* touches (voice-note player controls).
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MediaBubbleShell(
message: BitchatMessage,
currentUserNickname: String,
myPeerID: String,
showSender: Boolean,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onLongPress: (() -> Unit)?,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val palette = LocalBitchatPalette.current
val haptic = LocalHapticFeedback.current
val isSelf = message.isFromSelf(currentUserNickname, myPeerID)
val authorColor = remember(message, isSelf, palette) {
if (isSelf) palette.accentOrange else colorForPeer(peerIdentityForMessage(message), palette)
}
val corner = ChatVisualTokens.BubbleCornerRadius
val tail = ChatVisualTokens.BubbleTailRadius
val bubbleShape = if (isSelf) {
RoundedCornerShape(topStart = corner, topEnd = corner, bottomEnd = tail, bottomStart = corner)
} else {
RoundedCornerShape(topStart = corner, topEnd = corner, bottomEnd = corner, bottomStart = tail)
}
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = if (isSelf) Alignment.End else Alignment.Start,
) {
Box(
modifier = Modifier
.border(
width = 1.dp,
color = authorColor.copy(alpha = ChatVisualTokens.BubbleBorderAlpha),
shape = bubbleShape
)
.background(
color = authorColor.copy(alpha = ChatVisualTokens.BubbleBackgroundAlpha),
shape = bubbleShape
)
.combinedClickable(
enabled = onLongPress != null,
onClick = {},
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongPress?.invoke()
},
)
.padding(
horizontal = ChatVisualTokens.BubblePaddingHorizontal,
vertical = ChatVisualTokens.BubblePaddingVertical,
)
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (showSender && !isSelf) {
val senderText = remember(message, currentUserNickname, myPeerID, palette) {
formatTextMessageSender(
message = message,
currentUserNickname = currentUserNickname,
myPeerID = myPeerID,
palette = palette,
)
}
AnnotatedClickableText(
text = senderText,
annotationTags = listOf("nickname_click"),
onAnnotationClick = { tag, item ->
if (tag == "nickname_click" && onNicknameClick != null) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(item)
true
} else {
false
}
},
onLongPress = { onLongPress?.invoke() },
fontFamily = BitchatFontFamily,
softWrap = false,
overflow = TextOverflow.Ellipsis,
style = MessageSenderTextStyle,
)
}
content()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.End),
) {
Text(
text = formatTextMessageMetadata(message, timeFormatter),
fontFamily = BitchatFontFamily,
)
if (isSelf && message.isPrivate) {
message.deliveryStatus?.let { status ->
Spacer(Modifier.width(4.dp))
DeliveryStatusIcon(status = status)
}
}
}
}
}
}
}