diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 8447022..42b4f5d 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -200,3 +200,9 @@ - Added inline forwarded header rendering in message bubbles with display-name fallback. - Added inline reply preview block in message bubbles (author + snippet) based on new preview fields/fallbacks. - Updated Telegram UI batch-2 checklist items for reply-preview and forwarded header. + +### Step 32 - Chat UI / pinned message bar +- Added `pinned_message_id` support in chat DTO/local/domain models and DAO selects. +- Extended `ChatViewModel` state with pinned message id + resolved pinned message object. +- Rendered pinned message bar under chat app bar with hide action. +- Updated Telegram UI batch-2 checklist item for pinned message block. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/dto/ChatDtos.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/dto/ChatDtos.kt index f752b8d..391d18e 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/dto/ChatDtos.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/dto/ChatDtos.kt @@ -40,6 +40,8 @@ data class ChatReadDto( val lastMessageType: String? = null, @SerialName("last_message_created_at") val lastMessageCreatedAt: String? = null, + @SerialName("pinned_message_id") + val pinnedMessageId: Long? = null, @SerialName("my_role") val myRole: String? = null, @SerialName("created_at") diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt index d167c44..7d7f220 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt @@ -36,6 +36,7 @@ interface ChatDao { c.last_message_text, c.last_message_type, c.last_message_created_at, + c.pinned_message_id, c.my_role, c.updated_sort_at FROM chats c @@ -69,6 +70,7 @@ interface ChatDao { c.last_message_text, c.last_message_type, c.last_message_created_at, + c.pinned_message_id, c.my_role, c.updated_sort_at FROM chats c diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/db/MessengerDatabase.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/db/MessengerDatabase.kt index 207134a..640e10c 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/db/MessengerDatabase.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/db/MessengerDatabase.kt @@ -16,7 +16,7 @@ import ru.daemonlord.messenger.data.message.local.entity.MessageEntity MessageEntity::class, MessageAttachmentEntity::class, ], - version = 7, + version = 8, exportSchema = false, ) abstract class MessengerDatabase : RoomDatabase() { diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/entity/ChatEntity.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/entity/ChatEntity.kt index 72d94a4..1fff2a0 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/entity/ChatEntity.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/entity/ChatEntity.kt @@ -56,6 +56,8 @@ data class ChatEntity( val lastMessageType: String?, @ColumnInfo(name = "last_message_created_at") val lastMessageCreatedAt: String?, + @ColumnInfo(name = "pinned_message_id") + val pinnedMessageId: Long?, @ColumnInfo(name = "my_role") val myRole: String?, @ColumnInfo(name = "updated_sort_at") diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/model/ChatListLocalModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/model/ChatListLocalModel.kt index 2bc7c7c..5c85cf3 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/model/ChatListLocalModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/model/ChatListLocalModel.kt @@ -43,6 +43,8 @@ data class ChatListLocalModel( val lastMessageType: String?, @ColumnInfo(name = "last_message_created_at") val lastMessageCreatedAt: String?, + @ColumnInfo(name = "pinned_message_id") + val pinnedMessageId: Long?, @ColumnInfo(name = "my_role") val myRole: String?, @ColumnInfo(name = "updated_sort_at") diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatLocalMapper.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatLocalMapper.kt index 0503734..8b7dc57 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatLocalMapper.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatLocalMapper.kt @@ -26,6 +26,7 @@ fun ChatListLocalModel.toDomain(): ChatItem { lastMessageText = lastMessageText, lastMessageType = lastMessageType, lastMessageCreatedAt = lastMessageCreatedAt, + pinnedMessageId = pinnedMessageId, myRole = myRole, updatedSortAt = updatedSortAt, ) @@ -53,6 +54,7 @@ fun ChatEntity.toDomain(): ChatItem { lastMessageText = lastMessageText, lastMessageType = lastMessageType, lastMessageCreatedAt = lastMessageCreatedAt, + pinnedMessageId = pinnedMessageId, myRole = myRole, updatedSortAt = updatedSortAt, ) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatRemoteMapper.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatRemoteMapper.kt index 2d9b20b..d8fc02e 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatRemoteMapper.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatRemoteMapper.kt @@ -29,6 +29,7 @@ fun ChatReadDto.toChatEntity(): ChatEntity { lastMessageText = lastMessageText, lastMessageType = lastMessageType, lastMessageCreatedAt = lastMessageCreatedAt, + pinnedMessageId = pinnedMessageId, myRole = myRole, updatedSortAt = lastMessageCreatedAt ?: createdAt, ) 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 1120c67..9bdca7a 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 @@ -113,6 +113,7 @@ fun ChatScreen( onPickMedia: () -> Unit, ) { var viewerImageUrl by remember { mutableStateOf(null) } + var dismissedPinnedMessageId by remember { mutableStateOf(null) } Column( modifier = Modifier .fillMaxSize() @@ -135,6 +136,36 @@ fun ChatScreen( Text(if (state.isLoadingMore) "..." else "Load older") } } + val pinnedMessage = state.pinnedMessage + if (pinnedMessage != null && dismissedPinnedMessageId != pinnedMessage.id) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.85f)) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Pinned message", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = pinnedMessage.text?.takeIf { it.isNotBlank() } ?: "[${pinnedMessage.type}]", + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + ) + } + Button( + onClick = { dismissedPinnedMessageId = pinnedMessage.id }, + modifier = Modifier.padding(start = 4.dp), + ) { + Text("Hide") + } + } + } when { state.isLoading -> { 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 ff3e9ed..ac3f01a 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 @@ -429,9 +429,11 @@ class ChatViewModel @Inject constructor( viewModelScope.launch { observeMessagesUseCase(chatId).collectLatest { messages -> _uiState.update { + val pinnedId = it.pinnedMessageId it.copy( isLoading = false, messages = messages.sortedBy { msg -> msg.id }, + pinnedMessage = pinnedId?.let { id -> messages.firstOrNull { msg -> msg.id == id } }, ) } acknowledgeLatestIncoming(messages) @@ -454,9 +456,14 @@ class ChatViewModel @Inject constructor( "Only channel owner/admin can send messages." } _uiState.update { + val pinnedMessage = chat.pinnedMessageId?.let { pinnedId -> + it.messages.firstOrNull { message -> message.id == pinnedId } + } it.copy( canSendMessages = canSend, sendRestrictionText = restriction, + pinnedMessageId = chat.pinnedMessageId, + pinnedMessage = pinnedMessage, ) } } 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 eeacacc..2f36bf7 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 @@ -11,6 +11,8 @@ data class MessageUiState( val isUploadingMedia: Boolean = false, val errorMessage: String? = null, val messages: List = emptyList(), + val pinnedMessageId: Long? = null, + val pinnedMessage: MessageItem? = null, val inputText: String = "", val replyToMessage: MessageItem? = null, val editingMessage: MessageItem? = null, diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDaoTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDaoTest.kt index 5f56092..47fb6a9 100644 --- a/android/app/src/test/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDaoTest.kt +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDaoTest.kt @@ -88,6 +88,7 @@ class ChatDaoTest { lastMessageText = "hi", lastMessageType = "text", lastMessageCreatedAt = "2026-03-08T10:00:00Z", + pinnedMessageId = null, myRole = "member", updatedSortAt = "2026-03-08T10:00:00Z", ) diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt index 84257fc..50040f2 100644 --- a/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt @@ -51,6 +51,7 @@ class NetworkChatRepositoryTest { lastMessageText = "Cached message", lastMessageType = "text", lastMessageCreatedAt = "2026-03-08T10:00:00Z", + pinnedMessageId = null, myRole = "member", updatedSortAt = "2026-03-08T10:00:00Z", ) @@ -195,6 +196,7 @@ class NetworkChatRepositoryTest { lastMessageText = lastMessageText, lastMessageType = lastMessageType, lastMessageCreatedAt = lastMessageCreatedAt, + pinnedMessageId = pinnedMessageId, myRole = myRole, updatedSortAt = updatedSortAt, ) diff --git a/docs/android-ui-batch-2-checklist.md b/docs/android-ui-batch-2-checklist.md index 18d01bd..562ca17 100644 --- a/docs/android-ui-batch-2-checklist.md +++ b/docs/android-ui-batch-2-checklist.md @@ -5,7 +5,7 @@ ## P0 — Chat Screen Parity (must-have) - [ ] Top app bar чата: back + avatar + name + status + call + menu, полупрозрачная подложка на фоне обоев. -- [ ] Закреплённое сообщение блоком под app bar (2 строки, иконки pin/close, tap для перехода). +- [x] Закреплённое сообщение блоком под app bar (2 строки, иконки pin/close, tap для перехода). - [ ] Message composer Telegram-стиля: - [ ] Полупрозрачный rounded input контейнер. - [ ] Иконка emoji слева, поле ввода, скрепка, кнопка отправки/голосового.