diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 730142c..d404584 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index f9d5e87..c094c6e 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -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), + ) } } }