android: add pinned message bar support in chat
Some checks are pending
CI / test (push) Has started running
Some checks are pending
CI / test (push) Has started running
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 -> {
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 слева, поле ввода, скрепка, кнопка отправки/голосового.
|
||||||
|
|||||||
Reference in New Issue
Block a user