android: refine chat message bubbles and media blocks
This commit is contained in:
@@ -838,3 +838,17 @@
|
|||||||
- Updated composer baseline:
|
- Updated composer baseline:
|
||||||
- icon-based emoji/attach/send/mic controls,
|
- icon-based emoji/attach/send/mic controls,
|
||||||
- cleaner container styling closer to Telegram-like bottom bar.
|
- 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.
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@@ -937,32 +938,34 @@ private fun MessageBubble(
|
|||||||
) {
|
) {
|
||||||
val isOutgoing = message.isOutgoing
|
val isOutgoing = message.isOutgoing
|
||||||
val bubbleShape = if (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 {
|
} 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) {
|
val bubbleColor = if (isOutgoing) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
|
||||||
MaterialTheme.colorScheme.primaryContainer
|
val textColor = if (isOutgoing) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
|
||||||
|
val secondaryTextColor = if (isOutgoing) {
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f)
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
}
|
}
|
||||||
val alignment = if (isOutgoing) Alignment.End else Alignment.Start
|
|
||||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = alignment) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(0.92f),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start,
|
horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start,
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
) {
|
) {
|
||||||
if (isMultiSelecting && !isOutgoing) {
|
if (isMultiSelecting && !isOutgoing) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isSelected) Icons.Filled.RadioButtonChecked else Icons.Filled.RadioButtonUnchecked,
|
imageVector = if (isSelected) Icons.Filled.RadioButtonChecked else Icons.Filled.RadioButtonUnchecked,
|
||||||
contentDescription = if (isSelected) "Selected" else "Not selected",
|
contentDescription = if (isSelected) "Selected" else "Not selected",
|
||||||
modifier = Modifier.padding(end = 6.dp),
|
modifier = Modifier.padding(end = 6.dp, bottom = 10.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(0.92f)
|
.fillMaxWidth(0.84f)
|
||||||
|
.widthIn(min = 82.dp)
|
||||||
.background(
|
.background(
|
||||||
color = when {
|
color = when {
|
||||||
isSelected -> MaterialTheme.colorScheme.tertiaryContainer
|
isSelected -> MaterialTheme.colorScheme.tertiaryContainer
|
||||||
@@ -975,89 +978,98 @@ private fun MessageBubble(
|
|||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongPress,
|
onLongClick = onLongPress,
|
||||||
)
|
)
|
||||||
.padding(horizontal = 10.dp, vertical = 7.dp),
|
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!isOutgoing && !message.senderDisplayName.isNullOrBlank()) {
|
if (!isOutgoing && !message.senderDisplayName.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = message.senderDisplayName,
|
text = message.senderDisplayName,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.forwardedFromMessageId != null || !message.forwardedFromDisplayName.isNullOrBlank()) {
|
if (message.forwardedFromMessageId != null || !message.forwardedFromDisplayName.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = "Forwarded from ${message.forwardedFromDisplayName?.takeIf { it.isNotBlank() } ?: "#${message.forwardedFromMessageId ?: "?"}"}",
|
text = "Forwarded from ${message.forwardedFromDisplayName?.takeIf { it.isNotBlank() } ?: "#${message.forwardedFromMessageId ?: "?"}"}",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = if (isOutgoing) {
|
color = secondaryTextColor,
|
||||||
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.78f)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.replyToMessageId != null) {
|
if (message.replyToMessageId != null) {
|
||||||
val replyAuthor = message.replyPreviewSenderName?.takeIf { it.isNotBlank() }
|
val replyAuthor = message.replyPreviewSenderName?.takeIf { it.isNotBlank() } ?: "#${message.replyToMessageId}"
|
||||||
?: "#${message.replyToMessageId}"
|
|
||||||
val replySnippet = message.replyPreviewText?.takeIf { it.isNotBlank() } ?: "[media]"
|
val replySnippet = message.replyPreviewText?.takeIf { it.isNotBlank() } ?: "[media]"
|
||||||
Column(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp)
|
.clip(RoundedCornerShape(10.dp))
|
||||||
.background(
|
.background(
|
||||||
color = if (isOutgoing) {
|
if (isOutgoing) {
|
||||||
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.16f)
|
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.15f)
|
||||||
} else {
|
} 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),
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(3.dp)
|
||||||
|
.height(30.dp)
|
||||||
|
.clip(RoundedCornerShape(2.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.primary),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = replyAuthor,
|
text = replyAuthor,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = textColor,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = replySnippet,
|
text = replySnippet,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = secondaryTextColor,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val mainText = message.text?.takeIf { it.isNotBlank() }
|
||||||
|
if (mainText != null || message.attachments.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = message.text ?: "[${message.type}]",
|
text = mainText ?: "[${message.type}]",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = textColor,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (reactions.isNotEmpty()) {
|
if (reactions.isNotEmpty()) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
reactions.forEach { reaction ->
|
reactions.forEach { reaction ->
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "${reaction.emoji} ${reaction.count}",
|
text = "${reaction.emoji} ${reaction.count}",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (message.attachments.isNotEmpty()) {
|
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 imageAttachments = message.attachments.filter { it.fileType.lowercase().startsWith("image/") }
|
||||||
val nonImageAttachments = message.attachments.filterNot { it.fileType.lowercase().startsWith("image/") }
|
val nonImageAttachments = message.attachments.filterNot { it.fileType.lowercase().startsWith("image/") }
|
||||||
|
|
||||||
@@ -1069,7 +1081,8 @@ private fun MessageBubble(
|
|||||||
contentDescription = "Image",
|
contentDescription = "Image",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(180.dp)
|
.height(188.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.clickable { onAttachmentImageClick(single.fileUrl) },
|
.clickable { onAttachmentImageClick(single.fileUrl) },
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
@@ -1085,19 +1098,17 @@ private fun MessageBubble(
|
|||||||
contentDescription = "Image",
|
contentDescription = "Image",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.height(110.dp)
|
.height(112.dp)
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
.clickable { onAttachmentImageClick(image.fileUrl) },
|
.clickable { onAttachmentImageClick(image.fileUrl) },
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (rowItems.size == 1) {
|
if (rowItems.size == 1) Spacer(modifier = Modifier.weight(1f))
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
}
|
}
|
||||||
}
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val showAsFileList = nonImageAttachments.size > 1
|
val showAsFileList = nonImageAttachments.size > 1
|
||||||
@@ -1105,7 +1116,6 @@ private fun MessageBubble(
|
|||||||
val fileType = attachment.fileType.lowercase()
|
val fileType = attachment.fileType.lowercase()
|
||||||
when {
|
when {
|
||||||
fileType.startsWith("video/") -> {
|
fileType.startsWith("video/") -> {
|
||||||
Box {
|
|
||||||
if (message.type.contains("video_note", ignoreCase = true)) {
|
if (message.type.contains("video_note", ignoreCase = true)) {
|
||||||
CircleVideoAttachmentPlayer(url = attachment.fileUrl)
|
CircleVideoAttachmentPlayer(url = attachment.fileUrl)
|
||||||
} else {
|
} else {
|
||||||
@@ -1115,18 +1125,16 @@ private fun MessageBubble(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
fileType.startsWith("audio/") -> {
|
fileType.startsWith("audio/") -> {
|
||||||
Box {
|
|
||||||
AudioAttachmentPlayer(
|
AudioAttachmentPlayer(
|
||||||
url = attachment.fileUrl,
|
url = attachment.fileUrl,
|
||||||
waveform = message.attachmentWaveform,
|
waveform = message.attachmentWaveform,
|
||||||
isVoice = message.type.contains("voice", ignoreCase = true),
|
isVoice = message.type.contains("voice", ignoreCase = true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else -> {
|
else -> {
|
||||||
Box {
|
|
||||||
FileAttachmentRow(
|
FileAttachmentRow(
|
||||||
fileUrl = attachment.fileUrl,
|
fileUrl = attachment.fileUrl,
|
||||||
fileType = attachment.fileType,
|
fileType = attachment.fileType,
|
||||||
@@ -1136,23 +1144,23 @@ private fun MessageBubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.End,
|
||||||
) {
|
) {
|
||||||
val status = when (message.status) {
|
val status = when (message.status) {
|
||||||
"read" -> " \u2713\u2713"
|
"read" -> " ✓✓"
|
||||||
"delivered" -> " \u2713\u2713"
|
"delivered" -> " ✓✓"
|
||||||
"sent" -> " \u2713"
|
"sent" -> " ✓"
|
||||||
"pending" -> " ..."
|
"pending" -> " …"
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "${formatMessageTime(message.createdAt)}$status",
|
text = "${formatMessageTime(message.createdAt)}$status",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = secondaryTextColor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1160,12 +1168,11 @@ private fun MessageBubble(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isSelected) Icons.Filled.RadioButtonChecked else Icons.Filled.RadioButtonUnchecked,
|
imageVector = if (isSelected) Icons.Filled.RadioButtonChecked else Icons.Filled.RadioButtonUnchecked,
|
||||||
contentDescription = if (isSelected) "Selected" else "Not selected",
|
contentDescription = if (isSelected) "Selected" else "Not selected",
|
||||||
modifier = Modifier.padding(start = 6.dp),
|
modifier = Modifier.padding(start = 6.dp, bottom = 10.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun VideoAttachmentCard(
|
private fun VideoAttachmentCard(
|
||||||
|
|||||||
Reference in New Issue
Block a user