android: align channel chat UI with telegram-style feed
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-10 08:50:38 +03:00
parent 78934a5f28
commit f7ef10b011
5 changed files with 271 additions and 195 deletions

View File

@@ -957,3 +957,13 @@
- GIFs now sent as `gif`,
- sticker-like payloads sent as `sticker` (filename/mime detection).
- Added missing fallback resource theme declarations (`themes.xml`) to stabilize clean debug assembly on local builds.
### Step 131 - Channel chat Telegram-like visual alignment
- Added channel-aware chat rendering path:
- `MessageUiState` now carries `chatType` from `ChatViewModel`,
- channel timeline bubbles are rendered as wider post-like cards (left-aligned feed style).
- Refined channel message status presentation:
- post cards now show cleaner timestamp-only footer instead of direct-message style checks.
- Added dedicated read-only channel bottom bar (for non owner/admin):
- compact Telegram-like controls with search + centered `Включить звук` action + notifications icon.
- Kept existing full composer for roles allowed to post in channels (owner/admin).

View File

@@ -300,6 +300,7 @@ fun ChatScreen(
.map { (url, _) -> url }
.distinct()
}
val isChannelChat = state.chatType.equals("channel", ignoreCase = true)
val timelineItems = remember(state.messages) { buildChatTimelineItems(state.messages) }
var viewerImageIndex by remember { mutableStateOf<Int?>(null) }
var viewerVideoUrl by remember { mutableStateOf<String?>(null) }
@@ -688,7 +689,8 @@ fun ChatScreen(
val message = (item as ChatTimelineItem.MessageEntry).message
val isSelected = state.actionState.selectedMessageIds.contains(message.id)
MessageBubble(
message = message,
message = message,
isChannelChat = isChannelChat,
isSelected = isSelected,
isMultiSelecting = state.actionState.mode == MessageSelectionMode.MULTI,
isInlineHighlighted = state.highlightedMessageId == message.id,
@@ -1089,208 +1091,209 @@ fun ChatScreen(
}
}
Surface(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.imePadding()
.padding(horizontal = 10.dp, vertical = 6.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
shape = RoundedCornerShape(22.dp),
) {
Column(
if (isChannelChat && !state.canSendMessages) {
ChannelReadOnlyBar(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
.navigationBarsPadding()
.padding(horizontal = 10.dp, vertical = 6.dp),
)
} else {
Surface(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.imePadding()
.padding(horizontal = 10.dp, vertical = 6.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
shape = RoundedCornerShape(22.dp),
) {
Row(
Column(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
IconButton(
onClick = {
composerValue = applyInlineFormatting(composerValue, "**", "**")
onInputChanged(composerValue.text)
},
enabled = state.canSendMessages,
) { Icon(Icons.Filled.FormatBold, contentDescription = "Bold") }
IconButton(
onClick = {
composerValue = applyInlineFormatting(composerValue, "*", "*")
onInputChanged(composerValue.text)
},
enabled = state.canSendMessages,
) { Icon(Icons.Filled.FormatItalic, contentDescription = "Italic") }
IconButton(
onClick = {
composerValue = applyInlineFormatting(composerValue, "__", "__")
onInputChanged(composerValue.text)
},
enabled = state.canSendMessages,
) { Icon(Icons.Filled.FormatUnderlined, contentDescription = "Underline") }
IconButton(
onClick = {
composerValue = applyInlineFormatting(composerValue, "~~", "~~")
onInputChanged(composerValue.text)
},
enabled = state.canSendMessages,
) { Icon(Icons.Filled.StrikethroughS, contentDescription = "Strikethrough") }
IconButton(
onClick = {
composerValue = applyInlineFormatting(composerValue, "||", "||")
onInputChanged(composerValue.text)
},
enabled = state.canSendMessages,
) { Icon(Icons.Filled.VisibilityOff, contentDescription = "Spoiler") }
IconButton(
onClick = {
composerValue = applyInlineFormatting(composerValue, "`", "`")
onInputChanged(composerValue.text)
},
enabled = state.canSendMessages,
) { Icon(Icons.Filled.Code, contentDescription = "Monospace") }
IconButton(
onClick = {
composerValue = applyInlineFormatting(composerValue, "```\n", "\n```", "code")
onInputChanged(composerValue.text)
},
enabled = state.canSendMessages,
) { Icon(Icons.Filled.Code, contentDescription = "Code block") }
IconButton(
onClick = {
composerValue = applyQuoteFormatting(composerValue)
onInputChanged(composerValue.text)
},
enabled = state.canSendMessages,
) { Icon(Icons.Filled.FormatQuote, contentDescription = "Quote") }
IconButton(
onClick = {
composerValue = applyLinkFormatting(composerValue)
onInputChanged(composerValue.text)
},
enabled = state.canSendMessages,
) { Icon(Icons.Filled.Link, contentDescription = "Link") }
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f),
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = { showEmojiPicker = true },
onClick = {
composerValue = applyInlineFormatting(composerValue, "**", "**")
onInputChanged(composerValue.text)
},
enabled = state.canSendMessages,
) { Icon(Icons.Filled.FormatBold, contentDescription = "Bold") }
IconButton(
onClick = {
composerValue = applyInlineFormatting(composerValue, "*", "*")
onInputChanged(composerValue.text)
},
enabled = state.canSendMessages,
) { Icon(Icons.Filled.FormatItalic, contentDescription = "Italic") }
IconButton(
onClick = {
composerValue = applyInlineFormatting(composerValue, "__", "__")
onInputChanged(composerValue.text)
},
enabled = state.canSendMessages,
) { Icon(Icons.Filled.FormatUnderlined, contentDescription = "Underline") }
IconButton(
onClick = {
composerValue = applyInlineFormatting(composerValue, "~~", "~~")
onInputChanged(composerValue.text)
},
enabled = state.canSendMessages,
) { Icon(Icons.Filled.StrikethroughS, contentDescription = "Strikethrough") }
IconButton(
onClick = {
composerValue = applyInlineFormatting(composerValue, "||", "||")
onInputChanged(composerValue.text)
},
enabled = state.canSendMessages,
) { Icon(Icons.Filled.VisibilityOff, contentDescription = "Spoiler") }
IconButton(
onClick = {
composerValue = applyInlineFormatting(composerValue, "`", "`")
onInputChanged(composerValue.text)
},
enabled = state.canSendMessages,
) { Icon(Icons.Filled.Code, contentDescription = "Monospace") }
IconButton(
onClick = {
composerValue = applyInlineFormatting(composerValue, "```\n", "\n```", "code")
onInputChanged(composerValue.text)
},
enabled = state.canSendMessages,
) { Icon(Icons.Filled.Code, contentDescription = "Code block") }
IconButton(
onClick = {
composerValue = applyQuoteFormatting(composerValue)
onInputChanged(composerValue.text)
},
enabled = state.canSendMessages,
) { Icon(Icons.Filled.FormatQuote, contentDescription = "Quote") }
IconButton(
onClick = {
composerValue = applyLinkFormatting(composerValue)
onInputChanged(composerValue.text)
},
enabled = state.canSendMessages,
) { Icon(Icons.Filled.Link, contentDescription = "Link") }
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f),
) {
Icon(
imageVector = Icons.Filled.EmojiEmotions,
contentDescription = "Emoji",
IconButton(
onClick = { showEmojiPicker = true },
enabled = state.canSendMessages,
) {
Icon(
imageVector = Icons.Filled.EmojiEmotions,
contentDescription = "Emoji",
)
}
}
TextField(
value = composerValue,
onValueChange = {
composerValue = it
onInputChanged(it.text)
},
modifier = Modifier.weight(1f),
placeholder = { Text("Message") },
shape = RoundedCornerShape(14.dp),
maxLines = 4,
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f),
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
),
)
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f),
) {
IconButton(
onClick = onPickMedia,
enabled = state.canSendMessages && !state.isUploadingMedia && !state.isRecordingVoice,
) {
if (state.isUploadingMedia) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Icon(imageVector = Icons.Filled.AttachFile, contentDescription = "Attach")
}
}
}
val canSend = state.canSendMessages &&
!state.isSending &&
!state.isUploadingMedia &&
composerValue.text.isNotBlank()
if (canSend) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f),
) {
IconButton(
onClick = onSendClick,
enabled = state.canSendMessages && !state.isUploadingMedia,
) {
Icon(imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
}
}
} else {
VoiceHoldToRecordButton(
enabled = state.canSendMessages && !state.isUploadingMedia,
isLocked = state.isVoiceLocked,
onStart = onVoiceRecordStart,
onLock = onVoiceRecordLock,
onCancel = onVoiceRecordCancel,
onRelease = onVoiceRecordSend,
)
}
}
TextField(
value = composerValue,
onValueChange = {
composerValue = it
onInputChanged(it.text)
},
modifier = Modifier.weight(1f),
placeholder = { Text("Message") },
shape = RoundedCornerShape(14.dp),
maxLines = 4,
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f),
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
),
)
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f),
}
if (state.isRecordingVoice) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = onPickMedia,
enabled = state.canSendMessages && !state.isUploadingMedia && !state.isRecordingVoice,
) {
if (state.isUploadingMedia) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Icon(imageVector = Icons.Filled.AttachFile, contentDescription = "Attach")
}
}
}
val canSend = state.canSendMessages &&
!state.isSending &&
!state.isUploadingMedia &&
composerValue.text.isNotBlank()
if (canSend) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f),
) {
IconButton(
onClick = onSendClick,
enabled = state.canSendMessages && !state.isUploadingMedia,
) {
Icon(imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
}
}
} else {
VoiceHoldToRecordButton(
enabled = state.canSendMessages && !state.isUploadingMedia,
isLocked = state.isVoiceLocked,
onStart = onVoiceRecordStart,
onLock = onVoiceRecordLock,
onCancel = onVoiceRecordCancel,
onRelease = onVoiceRecordSend,
Text(
text = "Voice ${formatDuration(state.voiceRecordingDurationMs.toInt())}",
style = MaterialTheme.typography.labelMedium,
)
}
}
}
if (state.isRecordingVoice) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Voice ${formatDuration(state.voiceRecordingDurationMs.toInt())}",
style = MaterialTheme.typography.labelMedium,
)
Text(
text = state.voiceRecordingHint ?: "",
style = MaterialTheme.typography.labelSmall,
)
if (state.isVoiceLocked) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Button(onClick = onVoiceRecordCancel) { Text("Cancel") }
Button(onClick = onVoiceRecordSend) { Text("Send") }
Text(
text = state.voiceRecordingHint ?: "",
style = MaterialTheme.typography.labelSmall,
)
if (state.isVoiceLocked) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Button(onClick = onVoiceRecordCancel) { Text("Cancel") }
Button(onClick = onVoiceRecordSend) { Text("Send") }
}
}
}
}
}
}
if (!state.canSendMessages && !state.sendRestrictionText.isNullOrBlank()) {
Text(
text = state.sendRestrictionText,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
)
}
if (!state.errorMessage.isNullOrBlank()) {
Text(
text = state.errorMessage,
@@ -1624,6 +1627,7 @@ private fun Uri.readMediaPayload(context: Context): PickedMediaPayload? {
@OptIn(ExperimentalFoundationApi::class)
private fun MessageBubble(
message: MessageItem,
isChannelChat: Boolean,
isSelected: Boolean,
isMultiSelecting: Boolean,
isInlineHighlighted: Boolean,
@@ -1637,14 +1641,24 @@ private fun MessageBubble(
onLongPress: () -> Unit,
) {
val isOutgoing = message.isOutgoing
val bubbleShape = if (isOutgoing) {
val renderAsChannelPost = isChannelChat
val alignAsOutgoing = isOutgoing && !renderAsChannelPost
val bubbleShape = if (alignAsOutgoing) {
RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 18.dp, bottomEnd = 6.dp)
} else {
RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 6.dp, bottomEnd = 18.dp)
}
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) {
val bubbleColor = when {
renderAsChannelPost -> MaterialTheme.colorScheme.surface.copy(alpha = 0.44f)
isOutgoing -> MaterialTheme.colorScheme.primaryContainer
else -> MaterialTheme.colorScheme.surfaceVariant
}
val textColor = if (isOutgoing && !renderAsChannelPost) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
}
val secondaryTextColor = if (isOutgoing && !renderAsChannelPost) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
@@ -1652,10 +1666,10 @@ private fun MessageBubble(
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start,
horizontalArrangement = if (alignAsOutgoing) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom,
) {
if (isMultiSelecting && !isOutgoing) {
if (isMultiSelecting && !alignAsOutgoing) {
Icon(
imageVector = if (isSelected) Icons.Filled.RadioButtonChecked else Icons.Filled.RadioButtonUnchecked,
contentDescription = if (isSelected) "Selected" else "Not selected",
@@ -1664,8 +1678,8 @@ private fun MessageBubble(
}
Column(
modifier = Modifier
.fillMaxWidth(0.8f)
.widthIn(min = 82.dp)
.fillMaxWidth(if (renderAsChannelPost) 0.94f else 0.8f)
.widthIn(min = if (renderAsChannelPost) 120.dp else 82.dp)
.background(
color = when {
isSelected -> MaterialTheme.colorScheme.tertiaryContainer
@@ -1678,10 +1692,10 @@ private fun MessageBubble(
onClick = onClick,
onLongClick = onLongPress,
)
.padding(horizontal = 10.dp, vertical = 7.dp),
.padding(horizontal = if (renderAsChannelPost) 11.dp else 10.dp, vertical = 7.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
if (!isOutgoing && !message.senderDisplayName.isNullOrBlank()) {
if ((!alignAsOutgoing || renderAsChannelPost) && !message.senderDisplayName.isNullOrBlank()) {
Text(
text = message.senderDisplayName,
style = MaterialTheme.typography.labelMedium,
@@ -1706,7 +1720,7 @@ private fun MessageBubble(
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(
if (isOutgoing) {
if (alignAsOutgoing) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.15f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.18f)
@@ -1870,13 +1884,17 @@ private fun MessageBubble(
else -> ""
}
Text(
text = "${formatMessageTime(message.createdAt)}$status",
text = if (renderAsChannelPost) {
formatMessageTime(message.createdAt)
} else {
"${formatMessageTime(message.createdAt)}$status"
},
style = MaterialTheme.typography.labelSmall,
color = secondaryTextColor,
)
}
}
if (isMultiSelecting && isOutgoing) {
if (isMultiSelecting && alignAsOutgoing) {
Icon(
imageVector = if (isSelected) Icons.Filled.RadioButtonChecked else Icons.Filled.RadioButtonUnchecked,
contentDescription = if (isSelected) "Selected" else "Not selected",
@@ -2061,6 +2079,48 @@ private fun DaySeparatorChip(label: String) {
}
}
@Composable
private fun ChannelReadOnlyBar(
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.94f),
modifier = Modifier.size(46.dp),
) {
IconButton(onClick = { }) {
Icon(imageVector = Icons.Filled.Search, contentDescription = "Search in channel")
}
}
Surface(
shape = RoundedCornerShape(999.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.94f),
modifier = Modifier.weight(1f),
) {
Text(
text = "Включить звук",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
style = MaterialTheme.typography.bodyMedium,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.94f),
modifier = Modifier.size(46.dp),
) {
IconButton(onClick = { }) {
Icon(imageVector = Icons.Filled.Notifications, contentDescription = "Channel notifications")
}
}
}
}
private fun parseMessageLocalDate(createdAt: String): LocalDate? {
return runCatching {
Instant.parse(createdAt).atZone(ZoneId.systemDefault()).toLocalDate()

View File

@@ -617,6 +617,7 @@ class ChatViewModel @Inject constructor(
it.messages.firstOrNull { message -> message.id == pinnedId }
}
it.copy(
chatType = chat.type,
chatTitle = chatTitle,
chatSubtitle = chatSubtitle,
chatAvatarUrl = chat.avatarUrl ?: chat.counterpartAvatarUrl,

View File

@@ -13,6 +13,7 @@ data class MessageUiState(
val chatTitle: String = "",
val chatSubtitle: String = "",
val chatAvatarUrl: String? = null,
val chatType: String = "",
val messages: List<MessageItem> = emptyList(),
val pinnedMessageId: Long? = null,
val pinnedMessage: MessageItem? = null,