diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 1dabf17..3377f59 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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). diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index cb107ad..46fea63 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -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(null) } var viewerVideoUrl by remember { mutableStateOf(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() diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt index 6927ca7..6135a34 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt @@ -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, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt index f5a1928..6edd81e 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt @@ -13,6 +13,7 @@ data class MessageUiState( val chatTitle: String = "", val chatSubtitle: String = "", val chatAvatarUrl: String? = null, + val chatType: String = "", val messages: List = emptyList(), val pinnedMessageId: Long? = null, val pinnedMessage: MessageItem? = null, diff --git a/docs/android-checklist.md b/docs/android-checklist.md index a927c03..08d1538 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -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