feat: implement device verification flow with new UI components and protocol handling
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 строки текста
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user