From b3c121b8bc3cf8fc3b29f4d2eaec3268e2749eae Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 5 Apr 2026 19:57:52 +0300 Subject: [PATCH] feat: add private chat info card Show a compact Telegram-like counterpart card inside private chats. Reuse existing chat data for avatar, username, and status instead of adding new profile fetches. Display contact and blocked badges when the relationship state is already known. --- .../messenger/ui/chat/ChatScreen.kt | 118 ++++++++++++++++++ .../messenger/ui/chat/ChatViewModel.kt | 4 + .../messenger/ui/chat/MessageUiState.kt | 4 + .../app/src/main/res/values-ru/strings.xml | 2 + android/app/src/main/res/values/strings.xml | 2 + 5 files changed, 130 insertions(+) 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 9115e39..9fb9cb7 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 @@ -612,6 +612,7 @@ private fun ChatScreen( state.isCounterpartRelationshipResolved && !state.isCounterpartContact && !state.isCounterpartBlocked + val showPrivateInfoCard = isPrivateChat && state.counterpartUserId != null val canShowMembersTab = state.chatType.equals("group", ignoreCase = true) || (state.chatType.equals("channel", ignoreCase = true) && state.canManageMembers) val timelineItems = remember(state.messages, context) { buildChatTimelineItems(state.messages, context) } @@ -1165,6 +1166,26 @@ private fun ChatScreen( } } } + AnimatedVisibility( + visible = showPrivateInfoCard, + enter = fadeIn(animationSpec = tween(180)) + slideInVertically( + initialOffsetY = { -it / 4 }, + animationSpec = tween(180), + ), + exit = fadeOut(animationSpec = tween(120)) + slideOutVertically( + targetOffsetY = { -it / 4 }, + animationSpec = tween(120), + ), + ) { + PrivateChatInfoCard( + title = state.counterpartName?.takeIf { it.isNotBlank() } ?: state.chatTitle, + username = state.counterpartUsername, + avatarUrl = state.chatAvatarUrl, + statusText = state.baseChatSubtitle.takeIf { it.isNotBlank() }, + isContact = state.isCounterpartContact, + isBlocked = state.isCounterpartBlocked, + ) + } val strip = topAudioStrip if (strip != null) { Row( @@ -2242,6 +2263,103 @@ private enum class ChatViewerMediaType { Video, } +@Composable +private fun PrivateChatInfoCard( + title: String, + username: String?, + avatarUrl: String?, + statusText: String?, + isContact: Boolean, + isBlocked: Boolean, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.78f), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (!avatarUrl.isNullOrBlank()) { + AsyncImage( + model = avatarUrl, + contentDescription = "Private chat avatar", + modifier = Modifier + .size(54.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + ) + } else { + Box( + modifier = Modifier + .size(54.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = title.firstOrNull()?.uppercase() ?: "?", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title.ifBlank { stringResource(id = R.string.chat_unknown_user) }, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + username?.takeIf { it.isNotBlank() }?.let { + Text( + text = "@$it", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + statusText?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (isContact) { + AssistChip( + onClick = {}, + enabled = false, + label = { Text(stringResource(id = R.string.chat_private_info_contact)) }, + ) + } + if (isBlocked) { + AssistChip( + onClick = {}, + enabled = false, + label = { Text(stringResource(id = R.string.chat_private_info_blocked)) }, + ) + } + } + } + } +} + private data class ChatViewerMediaItem( val url: String, val type: ChatViewerMediaType, 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 c298284..a249e5e 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 @@ -778,6 +778,10 @@ class ChatViewModel @Inject constructor( it.copy( chatType = chat.type, counterpartUserId = chat.counterpartUserId, + counterpartName = chat.counterpartName, + counterpartUsername = chat.counterpartUsername, + counterpartIsOnline = chat.counterpartIsOnline, + counterpartLastSeenAt = chat.counterpartLastSeenAt, isCounterpartRelationshipResolved = if (chat.type.equals("private", ignoreCase = true) && chat.counterpartUserId != null) { it.isCounterpartRelationshipResolved } else { 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 25d94be..7d5bab1 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 @@ -19,6 +19,10 @@ data class MessageUiState( val chatAvatarUrl: String? = null, val chatType: String = "", val counterpartUserId: Long? = null, + val counterpartName: String? = null, + val counterpartUsername: String? = null, + val counterpartIsOnline: Boolean? = null, + val counterpartLastSeenAt: String? = null, val chatRole: String? = null, val chatMuted: Boolean = false, val chatUnreadCount: Int = 0, diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index d8e9570..a607e2b 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -142,6 +142,8 @@ Можно добавить этого человека в контакты или заблокировать прямо из чата. Добавить контакт Заблокировать + Контакт + Заблокирован группа канал Медиа diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index e77fc57..620b3ac 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -142,6 +142,8 @@ You can add this person to contacts or block them right from the chat. Add contact Block user + Contact + Blocked group channel Media