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`, - GIFs now sent as `gif`,
- sticker-like payloads sent as `sticker` (filename/mime detection). - 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. - 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 } .map { (url, _) -> url }
.distinct() .distinct()
} }
val isChannelChat = state.chatType.equals("channel", ignoreCase = true)
val timelineItems = remember(state.messages) { buildChatTimelineItems(state.messages) } val timelineItems = remember(state.messages) { buildChatTimelineItems(state.messages) }
var viewerImageIndex by remember { mutableStateOf<Int?>(null) } var viewerImageIndex by remember { mutableStateOf<Int?>(null) }
var viewerVideoUrl by remember { mutableStateOf<String?>(null) } var viewerVideoUrl by remember { mutableStateOf<String?>(null) }
@@ -689,6 +690,7 @@ fun ChatScreen(
val isSelected = state.actionState.selectedMessageIds.contains(message.id) val isSelected = state.actionState.selectedMessageIds.contains(message.id)
MessageBubble( MessageBubble(
message = message, message = message,
isChannelChat = isChannelChat,
isSelected = isSelected, isSelected = isSelected,
isMultiSelecting = state.actionState.mode == MessageSelectionMode.MULTI, isMultiSelecting = state.actionState.mode == MessageSelectionMode.MULTI,
isInlineHighlighted = state.highlightedMessageId == message.id, isInlineHighlighted = state.highlightedMessageId == message.id,
@@ -1089,6 +1091,14 @@ fun ChatScreen(
} }
} }
if (isChannelChat && !state.canSendMessages) {
ChannelReadOnlyBar(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 10.dp, vertical = 6.dp),
)
} else {
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -1282,13 +1292,6 @@ fun ChatScreen(
} }
} }
} }
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()) { if (!state.errorMessage.isNullOrBlank()) {
@@ -1624,6 +1627,7 @@ private fun Uri.readMediaPayload(context: Context): PickedMediaPayload? {
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
private fun MessageBubble( private fun MessageBubble(
message: MessageItem, message: MessageItem,
isChannelChat: Boolean,
isSelected: Boolean, isSelected: Boolean,
isMultiSelecting: Boolean, isMultiSelecting: Boolean,
isInlineHighlighted: Boolean, isInlineHighlighted: Boolean,
@@ -1637,14 +1641,24 @@ private fun MessageBubble(
onLongPress: () -> Unit, onLongPress: () -> Unit,
) { ) {
val isOutgoing = message.isOutgoing 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) RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 18.dp, bottomEnd = 6.dp)
} else { } else {
RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 6.dp, bottomEnd = 18.dp) 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 bubbleColor = when {
val textColor = if (isOutgoing) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface renderAsChannelPost -> MaterialTheme.colorScheme.surface.copy(alpha = 0.44f)
val secondaryTextColor = if (isOutgoing) { 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) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f)
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
@@ -1652,10 +1666,10 @@ private fun MessageBubble(
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start, horizontalArrangement = if (alignAsOutgoing) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
) { ) {
if (isMultiSelecting && !isOutgoing) { if (isMultiSelecting && !alignAsOutgoing) {
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",
@@ -1664,8 +1678,8 @@ private fun MessageBubble(
} }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.8f) .fillMaxWidth(if (renderAsChannelPost) 0.94f else 0.8f)
.widthIn(min = 82.dp) .widthIn(min = if (renderAsChannelPost) 120.dp else 82.dp)
.background( .background(
color = when { color = when {
isSelected -> MaterialTheme.colorScheme.tertiaryContainer isSelected -> MaterialTheme.colorScheme.tertiaryContainer
@@ -1678,10 +1692,10 @@ private fun MessageBubble(
onClick = onClick, onClick = onClick,
onLongClick = onLongPress, 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), verticalArrangement = Arrangement.spacedBy(3.dp),
) { ) {
if (!isOutgoing && !message.senderDisplayName.isNullOrBlank()) { if ((!alignAsOutgoing || renderAsChannelPost) && !message.senderDisplayName.isNullOrBlank()) {
Text( Text(
text = message.senderDisplayName, text = message.senderDisplayName,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
@@ -1706,7 +1720,7 @@ private fun MessageBubble(
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp))
.background( .background(
if (isOutgoing) { if (alignAsOutgoing) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.15f) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.15f)
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.18f) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.18f)
@@ -1870,13 +1884,17 @@ private fun MessageBubble(
else -> "" else -> ""
} }
Text( Text(
text = "${formatMessageTime(message.createdAt)}$status", text = if (renderAsChannelPost) {
formatMessageTime(message.createdAt)
} else {
"${formatMessageTime(message.createdAt)}$status"
},
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = secondaryTextColor, color = secondaryTextColor,
) )
} }
} }
if (isMultiSelecting && isOutgoing) { if (isMultiSelecting && alignAsOutgoing) {
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",
@@ -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? { private fun parseMessageLocalDate(createdAt: String): LocalDate? {
return runCatching { return runCatching {
Instant.parse(createdAt).atZone(ZoneId.systemDefault()).toLocalDate() 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.messages.firstOrNull { message -> message.id == pinnedId }
} }
it.copy( it.copy(
chatType = chat.type,
chatTitle = chatTitle, chatTitle = chatTitle,
chatSubtitle = chatSubtitle, chatSubtitle = chatSubtitle,
chatAvatarUrl = chat.avatarUrl ?: chat.counterpartAvatarUrl, chatAvatarUrl = chat.avatarUrl ?: chat.counterpartAvatarUrl,

View File

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

View File

@@ -87,6 +87,10 @@
- [x] Admin actions: add/remove/ban/unban/promote/demote - [x] Admin actions: add/remove/ban/unban/promote/demote
- [x] Ограничения канала: писать только owner/admin - [x] Ограничения канала: писать только owner/admin
- [x] Member visibility rules (скрытие списков/действий) - [x] Member visibility rules (скрытие списков/действий)
- [x] Channel chat visual pass (Telegram-like):
- post-style bubbles in channel timeline,
- read-only bottom bar for non-admin members (`Включить звук` style),
- cleaner channel feed density and spacing.
## 11. Поиск ## 11. Поиск
- [x] Глобальный поиск: users/chats/messages - [x] Глобальный поиск: users/chats/messages