android: refine chat message bubbles and media blocks
Some checks failed
Android CI / android (push) Has been cancelled
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-10 01:16:39 +03:00
parent 895c132eb2
commit 4aa4946e82
2 changed files with 150 additions and 129 deletions

View File

@@ -838,3 +838,17 @@
- Updated composer baseline:
- icon-based emoji/attach/send/mic controls,
- cleaner container styling closer to Telegram-like bottom bar.
### Step 120 - Message bubble layout pass (Telegram-like geometry)
- Reworked `MessageBubble` structure and density:
- cleaner outgoing/incoming bubble geometry,
- improved max width and alignment behavior,
- tighter paddings and spacing for mobile density.
- Redesigned forwarded/reply blocks:
- compact forwarded caption styling,
- reply block with accent stripe and nested preview text.
- Improved message meta line:
- cleaner time + status line placement and contrast.
- Refined reactions and attachments rendering inside bubbles:
- chip-like reaction containers,
- rounded image/file/media surfaces closer to Telegram-like visual rhythm.

View File

@@ -32,6 +32,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -937,127 +938,138 @@ private fun MessageBubble(
) {
val isOutgoing = message.isOutgoing
val bubbleShape = if (isOutgoing) {
RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 6.dp)
RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 18.dp, bottomEnd = 6.dp)
} else {
RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 6.dp, bottomEnd = 16.dp)
RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 6.dp, bottomEnd = 18.dp)
}
val bubbleColor = if (isOutgoing) {
MaterialTheme.colorScheme.primaryContainer
val bubbleColor = if (isOutgoing) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
val textColor = if (isOutgoing) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
val secondaryTextColor = if (isOutgoing) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f)
} else {
MaterialTheme.colorScheme.surfaceVariant
MaterialTheme.colorScheme.onSurfaceVariant
}
val alignment = if (isOutgoing) Alignment.End else Alignment.Start
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = alignment) {
Row(
modifier = Modifier.fillMaxWidth(0.92f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start,
) {
if (isMultiSelecting && !isOutgoing) {
Icon(
imageVector = if (isSelected) Icons.Filled.RadioButtonChecked else Icons.Filled.RadioButtonUnchecked,
contentDescription = if (isSelected) "Selected" else "Not selected",
modifier = Modifier.padding(end = 6.dp),
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom,
) {
if (isMultiSelecting && !isOutgoing) {
Icon(
imageVector = if (isSelected) Icons.Filled.RadioButtonChecked else Icons.Filled.RadioButtonUnchecked,
contentDescription = if (isSelected) "Selected" else "Not selected",
modifier = Modifier.padding(end = 6.dp, bottom = 10.dp),
)
}
Column(
modifier = Modifier
.fillMaxWidth(0.84f)
.widthIn(min = 82.dp)
.background(
color = when {
isSelected -> MaterialTheme.colorScheme.tertiaryContainer
isInlineHighlighted -> MaterialTheme.colorScheme.secondaryContainer
else -> bubbleColor
},
shape = bubbleShape,
)
}
Column(
modifier = Modifier
.fillMaxWidth(0.92f)
.background(
color = when {
isSelected -> MaterialTheme.colorScheme.tertiaryContainer
isInlineHighlighted -> MaterialTheme.colorScheme.secondaryContainer
else -> bubbleColor
},
shape = bubbleShape,
)
.combinedClickable(
onClick = onClick,
onLongClick = onLongPress,
)
.padding(horizontal = 10.dp, vertical = 7.dp),
) {
if (isSelected) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Selected",
modifier = Modifier.size(14.dp),
)
Text(
text = "Selected",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
)
}
}
.combinedClickable(
onClick = onClick,
onLongClick = onLongPress,
)
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (!isOutgoing && !message.senderDisplayName.isNullOrBlank()) {
Text(
text = message.senderDisplayName,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
if (message.forwardedFromMessageId != null || !message.forwardedFromDisplayName.isNullOrBlank()) {
Text(
text = "Forwarded from ${message.forwardedFromDisplayName?.takeIf { it.isNotBlank() } ?: "#${message.forwardedFromMessageId ?: "?"}"}",
style = MaterialTheme.typography.labelSmall,
color = if (isOutgoing) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.78f)
},
color = secondaryTextColor,
)
}
if (message.replyToMessageId != null) {
val replyAuthor = message.replyPreviewSenderName?.takeIf { it.isNotBlank() }
?: "#${message.replyToMessageId}"
val replyAuthor = message.replyPreviewSenderName?.takeIf { it.isNotBlank() } ?: "#${message.replyToMessageId}"
val replySnippet = message.replyPreviewText?.takeIf { it.isNotBlank() } ?: "[media]"
Column(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clip(RoundedCornerShape(10.dp))
.background(
color = if (isOutgoing) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.16f)
if (isOutgoing) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.15f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.16f)
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.18f)
},
shape = RoundedCornerShape(8.dp),
)
.padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = replyAuthor,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
Box(
modifier = Modifier
.width(3.dp)
.height(30.dp)
.clip(RoundedCornerShape(2.dp))
.background(MaterialTheme.colorScheme.primary),
)
Text(
text = replySnippet,
style = MaterialTheme.typography.labelSmall,
maxLines = 2,
)
}
}
Text(
text = message.text ?: "[${message.type}]",
style = MaterialTheme.typography.bodyMedium,
)
if (reactions.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
reactions.forEach { reaction ->
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(1.dp),
) {
Text(
text = "${reaction.emoji} ${reaction.count}",
text = replyAuthor,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = textColor,
)
Text(
text = replySnippet,
style = MaterialTheme.typography.labelSmall,
color = secondaryTextColor,
maxLines = 2,
)
}
}
}
val mainText = message.text?.takeIf { it.isNotBlank() }
if (mainText != null || message.attachments.isEmpty()) {
Text(
text = mainText ?: "[${message.type}]",
style = MaterialTheme.typography.bodyMedium,
color = textColor,
)
}
if (reactions.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
reactions.forEach { reaction ->
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
shape = RoundedCornerShape(999.dp),
) {
Text(
text = "${reaction.emoji} ${reaction.count}",
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
}
}
if (message.attachments.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(2.dp))
val imageAttachments = message.attachments.filter { it.fileType.lowercase().startsWith("image/") }
val nonImageAttachments = message.attachments.filterNot { it.fileType.lowercase().startsWith("image/") }
@@ -1069,7 +1081,8 @@ private fun MessageBubble(
contentDescription = "Image",
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.height(188.dp)
.clip(RoundedCornerShape(12.dp))
.clickable { onAttachmentImageClick(single.fileUrl) },
contentScale = ContentScale.Crop,
)
@@ -1085,19 +1098,17 @@ private fun MessageBubble(
contentDescription = "Image",
modifier = Modifier
.weight(1f)
.height(110.dp)
.height(112.dp)
.clip(RoundedCornerShape(10.dp))
.clickable { onAttachmentImageClick(image.fileUrl) },
contentScale = ContentScale.Crop,
)
}
if (rowItems.size == 1) {
Spacer(modifier = Modifier.weight(1f))
}
if (rowItems.size == 1) Spacer(modifier = Modifier.weight(1f))
}
Spacer(modifier = Modifier.height(4.dp))
}
}
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(2.dp))
}
val showAsFileList = nonImageAttachments.size > 1
@@ -1105,64 +1116,60 @@ private fun MessageBubble(
val fileType = attachment.fileType.lowercase()
when {
fileType.startsWith("video/") -> {
Box {
if (message.type.contains("video_note", ignoreCase = true)) {
CircleVideoAttachmentPlayer(url = attachment.fileUrl)
} else {
VideoAttachmentCard(
url = attachment.fileUrl,
fileType = attachment.fileType,
)
}
}
}
fileType.startsWith("audio/") -> {
Box {
AudioAttachmentPlayer(
if (message.type.contains("video_note", ignoreCase = true)) {
CircleVideoAttachmentPlayer(url = attachment.fileUrl)
} else {
VideoAttachmentCard(
url = attachment.fileUrl,
waveform = message.attachmentWaveform,
isVoice = message.type.contains("voice", ignoreCase = true),
fileType = attachment.fileType,
)
}
}
fileType.startsWith("audio/") -> {
AudioAttachmentPlayer(
url = attachment.fileUrl,
waveform = message.attachmentWaveform,
isVoice = message.type.contains("voice", ignoreCase = true),
)
}
else -> {
Box {
FileAttachmentRow(
fileUrl = attachment.fileUrl,
fileType = attachment.fileType,
fileSize = attachment.fileSize,
compact = showAsFileList,
)
}
FileAttachmentRow(
fileUrl = attachment.fileUrl,
fileType = attachment.fileType,
fileSize = attachment.fileSize,
compact = showAsFileList,
)
}
}
Spacer(modifier = Modifier.height(4.dp))
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
val status = when (message.status) {
"read" -> " \u2713\u2713"
"delivered" -> " \u2713\u2713"
"sent" -> " \u2713"
"pending" -> " ..."
"read" -> " ✓✓"
"delivered" -> " ✓✓"
"sent" -> " "
"pending" -> " "
else -> ""
}
Text(
text = "${formatMessageTime(message.createdAt)}$status",
style = MaterialTheme.typography.labelSmall,
color = secondaryTextColor,
)
}
}
if (isMultiSelecting && isOutgoing) {
Icon(
imageVector = if (isSelected) Icons.Filled.RadioButtonChecked else Icons.Filled.RadioButtonUnchecked,
contentDescription = if (isSelected) "Selected" else "Not selected",
modifier = Modifier.padding(start = 6.dp),
)
}
}
if (isMultiSelecting && isOutgoing) {
Icon(
imageVector = if (isSelected) Icons.Filled.RadioButtonChecked else Icons.Filled.RadioButtonUnchecked,
contentDescription = if (isSelected) "Selected" else "Not selected",
modifier = Modifier.padding(start = 6.dp, bottom = 10.dp),
)
}
}
}