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) }
@@ -688,7 +689,8 @@ fun ChatScreen(
val message = (item as ChatTimelineItem.MessageEntry).message val message = (item as ChatTimelineItem.MessageEntry).message
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,208 +1091,209 @@ fun ChatScreen(
} }
} }
Surface( if (isChannelChat && !state.canSendMessages) {
modifier = Modifier ChannelReadOnlyBar(
.fillMaxWidth()
.navigationBarsPadding()
.imePadding()
.padding(horizontal = 10.dp, vertical = 6.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
shape = RoundedCornerShape(22.dp),
) {
Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp), .navigationBarsPadding()
verticalArrangement = Arrangement.spacedBy(6.dp), .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 modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.horizontalScroll(rememberScrollState()), .padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
IconButton( Row(
onClick = { modifier = Modifier
composerValue = applyInlineFormatting(composerValue, "**", "**") .fillMaxWidth()
onInputChanged(composerValue.text) .horizontalScroll(rememberScrollState()),
}, horizontalArrangement = Arrangement.spacedBy(2.dp),
enabled = state.canSendMessages, verticalAlignment = Alignment.CenterVertically,
) { 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),
) { ) {
IconButton( IconButton(
onClick = { showEmojiPicker = true }, onClick = {
composerValue = applyInlineFormatting(composerValue, "**", "**")
onInputChanged(composerValue.text)
},
enabled = state.canSendMessages, 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( IconButton(
imageVector = Icons.Filled.EmojiEmotions, onClick = { showEmojiPicker = true },
contentDescription = "Emoji", 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, if (state.isRecordingVoice) {
onValueChange = { Row(
composerValue = it modifier = Modifier
onInputChanged(it.text) .fillMaxWidth()
}, .padding(horizontal = 10.dp, vertical = 6.dp),
modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceBetween,
placeholder = { Text("Message") }, verticalAlignment = Alignment.CenterVertically,
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( Text(
onClick = onPickMedia, text = "Voice ${formatDuration(state.voiceRecordingDurationMs.toInt())}",
enabled = state.canSendMessages && !state.isUploadingMedia && !state.isRecordingVoice, style = MaterialTheme.typography.labelMedium,
) {
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 = state.voiceRecordingHint ?: "",
} style = MaterialTheme.typography.labelSmall,
if (state.isRecordingVoice) { )
Row( if (state.isVoiceLocked) {
modifier = Modifier Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
.fillMaxWidth() Button(onClick = onVoiceRecordCancel) { Text("Cancel") }
.padding(horizontal = 10.dp, vertical = 6.dp), Button(onClick = onVoiceRecordSend) { Text("Send") }
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") }
} }
} }
} }
} }
} }
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()) {
Text( Text(
text = state.errorMessage, text = state.errorMessage,
@@ -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