feat: implement device verification flow with new UI components and protocol handling

This commit is contained in:
2026-02-18 04:40:22 +05:00
parent edff3b32c3
commit cacd6dc029
24 changed files with 1645 additions and 195 deletions

View File

@@ -66,6 +66,7 @@ import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition
import com.rosetta.messenger.R
import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.ProtocolManager
@@ -438,11 +439,13 @@ fun ChatDetailScreen(
}
// Динамический subtitle: typing > online > offline
val isSystemAccount = user.publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
val chatSubtitle =
when {
isSavedMessages -> "Notes"
isTyping -> "" // Пустая строка, используем компонент TypingIndicator
isOnline -> "online"
isSystemAccount -> "official account"
else -> "offline"
}
@@ -1041,7 +1044,9 @@ fun ChatDetailScreen(
}
}
// Кнопки действий
if (!isSavedMessages) {
if (!isSavedMessages &&
!isSystemAccount
) {
IconButton(
onClick = { /* TODO: Voice call */
}
@@ -1117,6 +1122,8 @@ fun ChatDetailScreen(
isDarkTheme,
isSavedMessages =
isSavedMessages,
isSystemAccount =
isSystemAccount,
isBlocked =
isBlocked,
onBlockClick = {
@@ -1878,6 +1885,8 @@ fun ChatDetailScreen(
message,
isDarkTheme =
isDarkTheme,
isSystemSafeChat =
isSystemAccount,
isSelectionMode =
isSelectionMode,
showTail =

View File

@@ -1695,6 +1695,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
* - Сохранение в БД в IO потоке
* - Поддержка Reply/Forward через attachments (как в React Native)
*/
private fun encryptAesChachaKey(plainKeyAndNonce: ByteArray, privateKey: String): String {
return CryptoManager.encryptWithPassword(
String(plainKeyAndNonce, Charsets.ISO_8859_1),
privateKey
)
}
fun sendMessage() {
val text = _inputText.value.trim()
val recipient = opponentKey
@@ -1793,6 +1800,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce // Для шифрования attachments
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
@@ -1916,6 +1924,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
toPublicKey = recipient
content = encryptedContent
chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
@@ -2022,6 +2031,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val messageAttachments = mutableListOf<MessageAttachment>()
@@ -2118,6 +2128,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
toPublicKey = recipientPublicKey
content = encryptedContent
chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
@@ -2369,6 +2380,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
@@ -2404,6 +2416,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
toPublicKey = recipient
content = encryptedContent
chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
@@ -2530,6 +2543,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
@@ -2568,6 +2582,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
toPublicKey = recipient
content = encryptedContent
chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
@@ -2765,6 +2780,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
@@ -2829,6 +2845,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
toPublicKey = recipient
content = encryptedContent
chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
@@ -2934,6 +2951,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
@@ -2968,6 +2986,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
toPublicKey = recipient
content = encryptedContent
chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
@@ -3135,6 +3154,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, userPrivateKey)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(userPrivateKey)
@@ -3179,6 +3199,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
toPublicKey = recipient
content = encryptedContent
chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId

View File

@@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
@@ -37,16 +38,21 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.airbnb.lottie.compose.*
import com.rosetta.messenger.R
import com.rosetta.messenger.BuildConfig
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.network.DeviceEntry
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
import com.rosetta.messenger.ui.chats.components.DebugLogsBottomSheet
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge
@@ -77,6 +83,11 @@ data class Chat(
val isPinned: Boolean = false
)
private enum class DeviceResolveAction {
ACCEPT,
DECLINE
}
// Avatar colors matching React Native app (Mantine inspired)
// Light theme colors (background lighter, text darker)
private val avatarColorsLight =
@@ -250,6 +261,8 @@ fun ChatsListScreen(
// Protocol connection state
val protocolState by ProtocolManager.state.collectAsState()
val syncLogs by ProtocolManager.debugLogs.collectAsState()
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
// 🔥 Пользователи, которые сейчас печатают
val typingUsers by ProtocolManager.typingUsers.collectAsState()
@@ -275,6 +288,10 @@ fun ChatsListScreen(
// Status dialog state
var showStatusDialog by remember { mutableStateOf(false) }
var showSyncLogs by remember { mutableStateOf(false) }
// Включаем UI логи только когда открыт bottom sheet, чтобы не перегружать композицию
LaunchedEffect(showSyncLogs) { ProtocolManager.enableUILogs(showSyncLogs) }
// 📬 Requests screen state
var showRequestsScreen by remember { mutableStateOf(false) }
@@ -298,6 +315,10 @@ fun ChatsListScreen(
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
var deviceResolveRequest by
remember {
mutableStateOf<Pair<DeviceEntry, DeviceResolveAction>?>(null)
}
// 🔥 Selection mode state
var selectedChatKeys by remember { mutableStateOf<Set<String>>(emptySet()) }
@@ -379,6 +400,11 @@ fun ChatsListScreen(
Color(
0xFF4CAF50
)
ProtocolState
.DEVICE_VERIFICATION_REQUIRED ->
Color(
0xFFFF9800
)
ProtocolState
.CONNECTING,
ProtocolState
@@ -443,6 +469,15 @@ fun ChatsListScreen(
color = textColor
)
}
ProtocolState.DEVICE_VERIFICATION_REQUIRED -> {
Text(
text = "Device verification required",
fontSize = 16.sp,
fontWeight =
FontWeight.Medium,
color = textColor
)
}
}
}
}
@@ -1161,6 +1196,18 @@ fun ChatsListScreen(
},
actions = {
if (!showRequestsScreen) {
IconButton(
onClick = {
showSyncLogs = true
}
) {
Icon(
TablerIcons.Bug,
contentDescription = "Sync logs",
tint = Color.White.copy(alpha = 0.92f)
)
}
IconButton(
onClick = {
if (protocolState ==
@@ -1508,6 +1555,33 @@ fun ChatsListScreen(
listBackgroundColor
)
) {
pendingDeviceVerification?.let { pendingDevice ->
item(key = "device_verification_banner_${pendingDevice.deviceId}") {
Column {
DeviceVerificationBanner(
device = pendingDevice,
isDarkTheme = isDarkTheme,
accountPublicKey = accountPublicKey,
avatarRepository = avatarRepository,
onAccept = {
deviceResolveRequest =
pendingDevice to
DeviceResolveAction.ACCEPT
},
onDecline = {
deviceResolveRequest =
pendingDevice to
DeviceResolveAction.DECLINE
}
)
Divider(
color = dividerColor,
thickness = 0.5.dp
)
}
}
}
if (requestsCount > 0) {
item(
key =
@@ -1819,9 +1893,173 @@ fun ChatsListScreen(
)
}
deviceResolveRequest?.let { (device, action) ->
DeviceResolveDialog(
isDarkTheme = isDarkTheme,
device = device,
action = action,
onDismiss = { deviceResolveRequest = null },
onConfirm = {
val request = deviceResolveRequest
deviceResolveRequest = null
if (request != null) {
when (request.second) {
DeviceResolveAction.ACCEPT -> {
ProtocolManager.acceptDevice(
request.first.deviceId
)
}
DeviceResolveAction.DECLINE -> {
ProtocolManager.declineDevice(
request.first.deviceId
)
}
}
}
}
)
}
if (showSyncLogs) {
DebugLogsBottomSheet(
logs = syncLogs,
isDarkTheme = isDarkTheme,
onDismiss = { showSyncLogs = false },
onClearLogs = { ProtocolManager.clearLogs() }
)
}
} // Close Box
}
@Composable
private fun DeviceResolveDialog(
isDarkTheme: Boolean,
device: DeviceEntry,
action: DeviceResolveAction,
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
val containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
val borderColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE8E8ED)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val isAccept = action == DeviceResolveAction.ACCEPT
val confirmColor = if (isAccept) PrimaryBlue else Color(0xFFFF3B30)
val accentBg =
if (isDarkTheme) confirmColor.copy(alpha = 0.18f)
else confirmColor.copy(alpha = 0.12f)
val composition by rememberLottieComposition(
LottieCompositionSpec.RawRes(
if (isAccept) R.raw.saved else R.raw.device_confirm
)
)
val progress by animateLottieCompositionAsState(
composition = composition,
iterations = LottieConstants.IterateForever
)
val deviceLabel = buildString {
append(device.deviceName.ifBlank { "Unknown device" })
if (device.deviceOs.isNotBlank()) {
append("")
append(device.deviceOs)
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
modifier =
Modifier.fillMaxWidth()
.padding(horizontal = 24.dp)
.widthIn(max = 380.dp),
color = containerColor,
shape = RoundedCornerShape(22.dp),
border = BorderStroke(1.dp, borderColor)
) {
Column(
modifier = Modifier.padding(horizontal = 18.dp, vertical = 18.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier =
Modifier.size(96.dp)
.clip(RoundedCornerShape(20.dp))
.background(accentBg),
contentAlignment = Alignment.Center
) {
LottieAnimation(
composition = composition,
progress = { progress },
modifier = Modifier.size(78.dp)
)
}
Spacer(modifier = Modifier.height(14.dp))
Text(
text = if (isAccept) "Approve new device?" else "Decline this login?",
fontWeight = FontWeight.Bold,
color = textColor,
fontSize = 19.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text =
if (isAccept) {
"Allow \"$deviceLabel\" to access your account?"
} else {
"Block login from \"$deviceLabel\"?"
},
color = secondaryTextColor,
textAlign = TextAlign.Center,
lineHeight = 19.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(14.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.weight(1f).height(42.dp),
shape = RoundedCornerShape(12.dp),
border = BorderStroke(
width = 1.dp,
color = if (isDarkTheme) Color(0xFF4A4F60) else Color(0xFFD9D9DE)
)
) {
Text("Cancel", color = secondaryTextColor)
}
Button(
onClick = onConfirm,
modifier = Modifier.weight(1f).height(42.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = confirmColor,
contentColor = Color.White
)
) {
Text(if (isAccept) "Approve" else "Decline login")
}
}
}
}
}
}
/**
* 🚀 Shimmer skeleton для списка чатов — показывается пока данные грузятся Имитирует 10 строк
* диалогов: аватар + 2 строки текста

View File

@@ -33,7 +33,10 @@ import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
@@ -244,6 +247,7 @@ fun TypingIndicator(isDarkTheme: Boolean) {
fun MessageBubble(
message: ChatMessage,
isDarkTheme: Boolean,
isSystemSafeChat: Boolean = false,
isSelectionMode: Boolean = false,
showTail: Boolean = true,
isGroupStart: Boolean = false,
@@ -322,30 +326,43 @@ fun MessageBubble(
else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
}
val isSafeSystemMessage =
isSystemSafeChat &&
!message.isOutgoing &&
message.replyData == null &&
message.forwardedMessages.isEmpty() &&
message.attachments.isEmpty() &&
message.text.isNotBlank()
// Telegram: bubbleRadius = 17dp, smallRad (хвостик) = 6dp
val bubbleShape =
remember(message.isOutgoing, showTail) {
RoundedCornerShape(
topStart = TelegramBubbleSpec.bubbleRadius,
topEnd = TelegramBubbleSpec.bubbleRadius,
bottomStart =
if (message.isOutgoing) TelegramBubbleSpec.bubbleRadius
else
(if (showTail) TelegramBubbleSpec.nearRadius
else TelegramBubbleSpec.bubbleRadius),
bottomEnd =
if (message.isOutgoing)
(if (showTail) TelegramBubbleSpec.nearRadius
else TelegramBubbleSpec.bubbleRadius)
else TelegramBubbleSpec.bubbleRadius
)
remember(message.isOutgoing, showTail, isSafeSystemMessage) {
if (isSafeSystemMessage) {
RoundedCornerShape(18.dp)
} else {
RoundedCornerShape(
topStart = TelegramBubbleSpec.bubbleRadius,
topEnd = TelegramBubbleSpec.bubbleRadius,
bottomStart =
if (message.isOutgoing) TelegramBubbleSpec.bubbleRadius
else
(if (showTail) TelegramBubbleSpec.nearRadius
else TelegramBubbleSpec.bubbleRadius),
bottomEnd =
if (message.isOutgoing)
(if (showTail) TelegramBubbleSpec.nearRadius
else TelegramBubbleSpec.bubbleRadius)
else TelegramBubbleSpec.bubbleRadius
)
}
}
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
Box(
modifier =
Modifier.fillMaxWidth().pointerInput(Unit) {
Modifier.fillMaxWidth().pointerInput(isSafeSystemMessage) {
if (isSafeSystemMessage) return@pointerInput
// 🔥 Простой горизонтальный свайп для reply
// Используем detectHorizontalDragGestures который лучше работает со
// скроллом
@@ -501,6 +518,7 @@ fun MessageBubble(
// Для фото + caption - padding только внизу для текста
val bubblePadding =
when {
isSafeSystemMessage -> PaddingValues(0.dp)
hasOnlyMedia -> PaddingValues(0.dp)
hasImageWithCaption -> PaddingValues(0.dp)
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
@@ -578,7 +596,9 @@ fun MessageBubble(
}
val bubbleWidthModifier =
if (hasImageWithCaption || hasOnlyMedia) {
if (isSafeSystemMessage) {
Modifier.widthIn(min = 220.dp, max = 320.dp)
} else if (hasImageWithCaption || hasOnlyMedia) {
Modifier.width(
photoWidth
) // 🔥 Фиксированная ширина = размер фото (убирает лишний
@@ -635,13 +655,25 @@ fun MessageBubble(
},
shape = bubbleShape
)
} else if (isSafeSystemMessage) {
Modifier.background(
if (isDarkTheme) Color(0xFF2A2A2D)
else Color(0xFFF0F0F4)
)
} else {
Modifier.background(bubbleColor)
}
)
.padding(bubblePadding)
) {
Column {
if (isSafeSystemMessage) {
SafeSystemMessageCard(
text = message.text,
timestamp = message.timestamp,
isDarkTheme = isDarkTheme
)
} else {
Column {
// 🔥 Forwarded messages (multiple, desktop parity)
if (message.forwardedMessages.isNotEmpty()) {
ForwardedMessagesBubble(
@@ -962,11 +994,86 @@ fun MessageBubble(
)
}
}
}
}
}
}
}
@Composable
private fun SafeSystemMessageCard(text: String, timestamp: Date, isDarkTheme: Boolean) {
val contentColor = if (isDarkTheme) Color(0xFFE8E9EE) else Color(0xFF1E1F23)
val timeColor = if (isDarkTheme) Color(0xFFB3B7C0) else Color(0xFF737983)
val annotatedText = remember(text) { buildSafeSystemAnnotatedText(text) }
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
Column(
modifier =
Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, top = 12.dp, bottom = 8.dp)
) {
Text(
text = annotatedText,
color = contentColor,
fontSize = 16.sp,
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = timeFormat.format(timestamp),
color = timeColor,
fontSize = 11.sp,
modifier = Modifier.align(Alignment.End)
)
}
}
private fun buildSafeSystemAnnotatedText(text: String) = buildAnnotatedString {
val boldStyle = SpanStyle(fontWeight = FontWeight.SemiBold)
val lines = text.lines()
lines.forEachIndexed { index, line ->
when {
index == 0 && line.isNotBlank() -> {
withStyle(boldStyle) { append(line) }
}
line.startsWith("We detected a login to your account from ") -> {
val prefix = "We detected a login to your account from "
val marker = " a new device by seed phrase"
val tail = line.removePrefix(prefix)
val markerIndex = tail.indexOf(marker)
if (markerIndex > 0) {
val ip = tail.substring(0, markerIndex)
append(prefix)
withStyle(boldStyle) { append(ip) }
append(" a new device ")
withStyle(boldStyle) { append("by seed phrase") }
append(tail.substring(markerIndex + marker.length))
} else {
append(line)
}
}
line.startsWith("Arch:") ||
line.startsWith("IP:") ||
line.startsWith("Device:") ||
line.startsWith("ID:") -> {
val separatorIndex = line.indexOf(':')
if (separatorIndex > 0) {
withStyle(boldStyle) { append(line.substring(0, separatorIndex + 1)) }
if (separatorIndex + 1 < line.length) {
append(line.substring(separatorIndex + 1))
}
} else {
append(line)
}
}
else -> append(line)
}
if (index < lines.lastIndex) append('\n')
}
}
/** Animated message status indicator */
@Composable
fun AnimatedMessageStatus(
@@ -1722,6 +1829,7 @@ fun KebabMenu(
onDismiss: () -> Unit,
isDarkTheme: Boolean,
isSavedMessages: Boolean,
isSystemAccount: Boolean = false,
isBlocked: Boolean,
onBlockClick: () -> Unit,
onUnblockClick: () -> Unit,
@@ -1752,7 +1860,7 @@ fun KebabMenu(
dismissOnClickOutside = true
)
) {
if (!isSavedMessages) {
if (!isSavedMessages && !isSystemAccount) {
KebabMenuItem(
icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block,
text = if (isBlocked) "Unblock User" else "Block User",

View File

@@ -0,0 +1,124 @@
package com.rosetta.messenger.ui.chats.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.DeviceEntry
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
@Composable
fun DeviceVerificationBanner(
device: DeviceEntry,
isDarkTheme: Boolean,
accountPublicKey: String,
avatarRepository: AvatarRepository?,
onAccept: () -> Unit,
onDecline: () -> Unit
) {
val itemBackground = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val titleColor = if (isDarkTheme) Color.White else Color.Black
val subtitleColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val acceptColor = PrimaryBlue
val declineColor = Color(0xFFFF3B30)
val loginText =
buildString {
append("New login from ")
append(device.deviceName)
if (device.deviceOs.isNotBlank()) {
append(" (")
append(device.deviceOs)
append(")")
}
append(". Is it you?")
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(itemBackground)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Row {
AvatarImage(
publicKey = accountPublicKey,
avatarRepository = avatarRepository,
size = 56.dp,
isDarkTheme = isDarkTheme
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Someone just got access to your messages!",
color = titleColor,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(3.dp))
Text(
text = loginText,
color = subtitleColor,
fontSize = 13.sp,
lineHeight = 17.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(6.dp))
Row {
TextButton(
onClick = onAccept,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.height(32.dp)
) {
Text(
text = "Yes, it's me",
color = acceptColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.width(12.dp))
TextButton(
onClick = onDecline,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.height(32.dp)
) {
Text(
text = "No, it's not me!",
color = declineColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
}
}