diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 402bb395..8eb76c44 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -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 -> diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index a5ce8864..0fc3b622 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -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) { diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index ce7ba470..5644e094 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -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, + imageShape: androidx.compose.foundation.shape.RoundedCornerShape, + onImageClick: ((String, List, 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)) + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/MediaBubbleShell.kt b/app/src/main/java/com/bitchat/android/ui/media/MediaBubbleShell.kt new file mode 100644 index 00000000..bc69dec3 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/media/MediaBubbleShell.kt @@ -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) + } + } + } + } + } + } +}