android: add pinned message bar support in chat
Some checks are pending
CI / test (push) Has started running

This commit is contained in:
Codex
2026-03-09 14:02:03 +03:00
parent ade92e4a86
commit 651d53f3df
14 changed files with 62 additions and 2 deletions

View File

@@ -200,3 +200,9 @@
- Added inline forwarded header rendering in message bubbles with display-name fallback. - 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. - 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. - 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.

View File

@@ -40,6 +40,8 @@ data class ChatReadDto(
val lastMessageType: String? = null, val lastMessageType: String? = null,
@SerialName("last_message_created_at") @SerialName("last_message_created_at")
val lastMessageCreatedAt: String? = null, val lastMessageCreatedAt: String? = null,
@SerialName("pinned_message_id")
val pinnedMessageId: Long? = null,
@SerialName("my_role") @SerialName("my_role")
val myRole: String? = null, val myRole: String? = null,
@SerialName("created_at") @SerialName("created_at")

View File

@@ -36,6 +36,7 @@ interface ChatDao {
c.last_message_text, c.last_message_text,
c.last_message_type, c.last_message_type,
c.last_message_created_at, c.last_message_created_at,
c.pinned_message_id,
c.my_role, c.my_role,
c.updated_sort_at c.updated_sort_at
FROM chats c FROM chats c
@@ -69,6 +70,7 @@ interface ChatDao {
c.last_message_text, c.last_message_text,
c.last_message_type, c.last_message_type,
c.last_message_created_at, c.last_message_created_at,
c.pinned_message_id,
c.my_role, c.my_role,
c.updated_sort_at c.updated_sort_at
FROM chats c FROM chats c

View File

@@ -16,7 +16,7 @@ import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
MessageEntity::class, MessageEntity::class,
MessageAttachmentEntity::class, MessageAttachmentEntity::class,
], ],
version = 7, version = 8,
exportSchema = false, exportSchema = false,
) )
abstract class MessengerDatabase : RoomDatabase() { abstract class MessengerDatabase : RoomDatabase() {

View File

@@ -56,6 +56,8 @@ data class ChatEntity(
val lastMessageType: String?, val lastMessageType: String?,
@ColumnInfo(name = "last_message_created_at") @ColumnInfo(name = "last_message_created_at")
val lastMessageCreatedAt: String?, val lastMessageCreatedAt: String?,
@ColumnInfo(name = "pinned_message_id")
val pinnedMessageId: Long?,
@ColumnInfo(name = "my_role") @ColumnInfo(name = "my_role")
val myRole: String?, val myRole: String?,
@ColumnInfo(name = "updated_sort_at") @ColumnInfo(name = "updated_sort_at")

View File

@@ -43,6 +43,8 @@ data class ChatListLocalModel(
val lastMessageType: String?, val lastMessageType: String?,
@ColumnInfo(name = "last_message_created_at") @ColumnInfo(name = "last_message_created_at")
val lastMessageCreatedAt: String?, val lastMessageCreatedAt: String?,
@ColumnInfo(name = "pinned_message_id")
val pinnedMessageId: Long?,
@ColumnInfo(name = "my_role") @ColumnInfo(name = "my_role")
val myRole: String?, val myRole: String?,
@ColumnInfo(name = "updated_sort_at") @ColumnInfo(name = "updated_sort_at")

View File

@@ -26,6 +26,7 @@ fun ChatListLocalModel.toDomain(): ChatItem {
lastMessageText = lastMessageText, lastMessageText = lastMessageText,
lastMessageType = lastMessageType, lastMessageType = lastMessageType,
lastMessageCreatedAt = lastMessageCreatedAt, lastMessageCreatedAt = lastMessageCreatedAt,
pinnedMessageId = pinnedMessageId,
myRole = myRole, myRole = myRole,
updatedSortAt = updatedSortAt, updatedSortAt = updatedSortAt,
) )
@@ -53,6 +54,7 @@ fun ChatEntity.toDomain(): ChatItem {
lastMessageText = lastMessageText, lastMessageText = lastMessageText,
lastMessageType = lastMessageType, lastMessageType = lastMessageType,
lastMessageCreatedAt = lastMessageCreatedAt, lastMessageCreatedAt = lastMessageCreatedAt,
pinnedMessageId = pinnedMessageId,
myRole = myRole, myRole = myRole,
updatedSortAt = updatedSortAt, updatedSortAt = updatedSortAt,
) )

View File

@@ -29,6 +29,7 @@ fun ChatReadDto.toChatEntity(): ChatEntity {
lastMessageText = lastMessageText, lastMessageText = lastMessageText,
lastMessageType = lastMessageType, lastMessageType = lastMessageType,
lastMessageCreatedAt = lastMessageCreatedAt, lastMessageCreatedAt = lastMessageCreatedAt,
pinnedMessageId = pinnedMessageId,
myRole = myRole, myRole = myRole,
updatedSortAt = lastMessageCreatedAt ?: createdAt, updatedSortAt = lastMessageCreatedAt ?: createdAt,
) )

View File

@@ -113,6 +113,7 @@ fun ChatScreen(
onPickMedia: () -> Unit, onPickMedia: () -> Unit,
) { ) {
var viewerImageUrl by remember { mutableStateOf<String?>(null) } var viewerImageUrl by remember { mutableStateOf<String?>(null) }
var dismissedPinnedMessageId by remember { mutableStateOf<Long?>(null) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -135,6 +136,36 @@ fun ChatScreen(
Text(if (state.isLoadingMore) "..." else "Load older") 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 { when {
state.isLoading -> { state.isLoading -> {

View File

@@ -429,9 +429,11 @@ class ChatViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
observeMessagesUseCase(chatId).collectLatest { messages -> observeMessagesUseCase(chatId).collectLatest { messages ->
_uiState.update { _uiState.update {
val pinnedId = it.pinnedMessageId
it.copy( it.copy(
isLoading = false, isLoading = false,
messages = messages.sortedBy { msg -> msg.id }, messages = messages.sortedBy { msg -> msg.id },
pinnedMessage = pinnedId?.let { id -> messages.firstOrNull { msg -> msg.id == id } },
) )
} }
acknowledgeLatestIncoming(messages) acknowledgeLatestIncoming(messages)
@@ -454,9 +456,14 @@ class ChatViewModel @Inject constructor(
"Only channel owner/admin can send messages." "Only channel owner/admin can send messages."
} }
_uiState.update { _uiState.update {
val pinnedMessage = chat.pinnedMessageId?.let { pinnedId ->
it.messages.firstOrNull { message -> message.id == pinnedId }
}
it.copy( it.copy(
canSendMessages = canSend, canSendMessages = canSend,
sendRestrictionText = restriction, sendRestrictionText = restriction,
pinnedMessageId = chat.pinnedMessageId,
pinnedMessage = pinnedMessage,
) )
} }
} }

View File

@@ -11,6 +11,8 @@ data class MessageUiState(
val isUploadingMedia: Boolean = false, val isUploadingMedia: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val messages: List<MessageItem> = emptyList(), val messages: List<MessageItem> = emptyList(),
val pinnedMessageId: Long? = null,
val pinnedMessage: MessageItem? = null,
val inputText: String = "", val inputText: String = "",
val replyToMessage: MessageItem? = null, val replyToMessage: MessageItem? = null,
val editingMessage: MessageItem? = null, val editingMessage: MessageItem? = null,

View File

@@ -88,6 +88,7 @@ class ChatDaoTest {
lastMessageText = "hi", lastMessageText = "hi",
lastMessageType = "text", lastMessageType = "text",
lastMessageCreatedAt = "2026-03-08T10:00:00Z", lastMessageCreatedAt = "2026-03-08T10:00:00Z",
pinnedMessageId = null,
myRole = "member", myRole = "member",
updatedSortAt = "2026-03-08T10:00:00Z", updatedSortAt = "2026-03-08T10:00:00Z",
) )

View File

@@ -51,6 +51,7 @@ class NetworkChatRepositoryTest {
lastMessageText = "Cached message", lastMessageText = "Cached message",
lastMessageType = "text", lastMessageType = "text",
lastMessageCreatedAt = "2026-03-08T10:00:00Z", lastMessageCreatedAt = "2026-03-08T10:00:00Z",
pinnedMessageId = null,
myRole = "member", myRole = "member",
updatedSortAt = "2026-03-08T10:00:00Z", updatedSortAt = "2026-03-08T10:00:00Z",
) )
@@ -195,6 +196,7 @@ class NetworkChatRepositoryTest {
lastMessageText = lastMessageText, lastMessageText = lastMessageText,
lastMessageType = lastMessageType, lastMessageType = lastMessageType,
lastMessageCreatedAt = lastMessageCreatedAt, lastMessageCreatedAt = lastMessageCreatedAt,
pinnedMessageId = pinnedMessageId,
myRole = myRole, myRole = myRole,
updatedSortAt = updatedSortAt, updatedSortAt = updatedSortAt,
) )

View File

@@ -5,7 +5,7 @@
## P0 — Chat Screen Parity (must-have) ## P0 — Chat Screen Parity (must-have)
- [ ] Top app bar чата: back + avatar + name + status + call + menu, полупрозрачная подложка на фоне обоев. - [ ] 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-стиля: - [ ] Message composer Telegram-стиля:
- [ ] Полупрозрачный rounded input контейнер. - [ ] Полупрозрачный rounded input контейнер.
- [ ] Иконка emoji слева, поле ввода, скрепка, кнопка отправки/голосового. - [ ] Иконка emoji слева, поле ввода, скрепка, кнопка отправки/голосового.