feat: add private chat info card
Some checks failed
Android CI / android (push) Failing after 5m42s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled

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.
This commit is contained in:
2026-04-05 19:57:52 +03:00
parent f012733df3
commit b3c121b8bc
5 changed files with 130 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -142,6 +142,8 @@
<string name="chat_unknown_user_banner_subtitle">Можно добавить этого человека в контакты или заблокировать прямо из чата.</string>
<string name="chat_unknown_user_add_contact">Добавить контакт</string>
<string name="chat_unknown_user_block">Заблокировать</string>
<string name="chat_private_info_contact">Контакт</string>
<string name="chat_private_info_blocked">Заблокирован</string>
<string name="chat_type_group">группа</string>
<string name="chat_type_channel">канал</string>
<string name="chat_info_tab_media">Медиа</string>

View File

@@ -142,6 +142,8 @@
<string name="chat_unknown_user_banner_subtitle">You can add this person to contacts or block them right from the chat.</string>
<string name="chat_unknown_user_add_contact">Add contact</string>
<string name="chat_unknown_user_block">Block user</string>
<string name="chat_private_info_contact">Contact</string>
<string name="chat_private_info_blocked">Blocked</string>
<string name="chat_type_group">group</string>
<string name="chat_type_channel">channel</string>
<string name="chat_info_tab_media">Media</string>