android: refine chat message bubbles and media blocks
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user