android: align channel chat UI with telegram-style feed
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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) }
|
||||
@@ -689,6 +690,7 @@ fun ChatScreen(
|
||||
val isSelected = state.actionState.selectedMessageIds.contains(message.id)
|
||||
MessageBubble(
|
||||
message = message,
|
||||
isChannelChat = isChannelChat,
|
||||
isSelected = isSelected,
|
||||
isMultiSelecting = state.actionState.mode == MessageSelectionMode.MULTI,
|
||||
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(
|
||||
modifier = Modifier
|
||||
.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()) {
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -87,6 +87,10 @@
|
||||
- [x] Admin actions: add/remove/ban/unban/promote/demote
|
||||
- [x] Ограничения канала: писать только owner/admin
|
||||
- [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. Поиск
|
||||
- [x] Глобальный поиск: users/chats/messages
|
||||
|
||||
Reference in New Issue
Block a user