android: align channel chat UI with telegram-style feed
This commit is contained in:
@@ -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).
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user