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 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.

View File

@@ -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")

View File

@@ -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

View File

@@ -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() {

View File

@@ -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")

View File

@@ -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")

View File

@@ -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,
)

View File

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

View File

@@ -113,6 +113,7 @@ fun ChatScreen(
onPickMedia: () -> Unit,
) {
var viewerImageUrl by remember { mutableStateOf<String?>(null) }
var dismissedPinnedMessageId by remember { mutableStateOf<Long?>(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 -> {

View File

@@ -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,
)
}
}

View File

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

View File

@@ -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",
)

View File

@@ -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,
)

View File

@@ -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 слева, поле ввода, скрепка, кнопка отправки/голосового.