Фикс: поведение синхронизации, проработка UX, проработка UI в групповых чатах, проработка анимаций AuthFlow
This commit is contained in:
@@ -1590,8 +1590,7 @@ fun MainScreen(
|
|||||||
isVisible = isSearchVisible,
|
isVisible = isSearchVisible,
|
||||||
onBack = { navStack = navStack.filterNot { it is Screen.Search } },
|
onBack = { navStack = navStack.filterNot { it is Screen.Search } },
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
layer = 1,
|
layer = 1
|
||||||
deferToChildren = true
|
|
||||||
) {
|
) {
|
||||||
// Экран поиска
|
// Экран поиска
|
||||||
SearchScreen(
|
SearchScreen(
|
||||||
|
|||||||
@@ -508,6 +508,7 @@ interface MessageDao {
|
|||||||
WHERE account = :account
|
WHERE account = :account
|
||||||
AND from_me = 1
|
AND from_me = 1
|
||||||
AND delivered = 0
|
AND delivered = 0
|
||||||
|
AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]')
|
||||||
AND timestamp >= :minTimestamp
|
AND timestamp >= :minTimestamp
|
||||||
ORDER BY timestamp ASC
|
ORDER BY timestamp ASC
|
||||||
"""
|
"""
|
||||||
@@ -524,6 +525,7 @@ interface MessageDao {
|
|||||||
WHERE account = :account
|
WHERE account = :account
|
||||||
AND from_me = 1
|
AND from_me = 1
|
||||||
AND delivered = 0
|
AND delivered = 0
|
||||||
|
AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]')
|
||||||
AND timestamp < :maxTimestamp
|
AND timestamp < :maxTimestamp
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -35,19 +34,18 @@ fun SeedPhraseScreen(
|
|||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
val cardBackground = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5)
|
|
||||||
|
|
||||||
var seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) }
|
var seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
var isGenerating by remember { mutableStateOf(true) }
|
|
||||||
var hasCopied by remember { mutableStateOf(false) }
|
var hasCopied by remember { mutableStateOf(false) }
|
||||||
var visible by remember { mutableStateOf(false) }
|
var visible by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
delay(100)
|
// Генерируем фразу сразу, без задержек
|
||||||
seedPhrase = CryptoManager.generateSeedPhrase()
|
seedPhrase = CryptoManager.generateSeedPhrase()
|
||||||
isGenerating = false
|
// Даем микро-паузу, чтобы верстка отрисовалась, и запускаем анимацию
|
||||||
|
delay(50)
|
||||||
visible = true
|
visible = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +57,7 @@ fun SeedPhraseScreen(
|
|||||||
.navigationBarsPadding()
|
.navigationBarsPadding()
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Simple top bar
|
// Top bar
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -108,126 +106,106 @@ fun SeedPhraseScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
// Two column layout
|
// Сетка со словами (без Crossfade и лоадера)
|
||||||
if (isGenerating) {
|
if (seedPhrase.isNotEmpty()) {
|
||||||
Box(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
.height(300.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator(
|
// Левая колонка (1-6)
|
||||||
color = PrimaryBlue,
|
Column(
|
||||||
strokeWidth = 2.dp,
|
modifier = Modifier.weight(1f),
|
||||||
modifier = Modifier.size(40.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = visible,
|
|
||||||
enter = fadeIn(tween(500, delayMillis = 200))
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
) {
|
||||||
// Left column (words 1-6)
|
for (i in 0..5) {
|
||||||
Column(
|
AnimatedWordItem(
|
||||||
modifier = Modifier.weight(1f),
|
number = i + 1,
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
word = seedPhrase[i],
|
||||||
) {
|
isDarkTheme = isDarkTheme,
|
||||||
for (i in 0..5) {
|
visible = visible,
|
||||||
AnimatedWordItem(
|
delay = i * 60
|
||||||
number = i + 1,
|
)
|
||||||
word = seedPhrase[i],
|
|
||||||
isDarkTheme = isDarkTheme,
|
|
||||||
visible = visible,
|
|
||||||
delay = 300 + (i * 50)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Right column (words 7-12)
|
|
||||||
Column(
|
// Правая колонка (7-12)
|
||||||
modifier = Modifier.weight(1f),
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
modifier = Modifier.weight(1f),
|
||||||
) {
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
for (i in 6..11) {
|
) {
|
||||||
AnimatedWordItem(
|
for (i in 6..11) {
|
||||||
number = i + 1,
|
AnimatedWordItem(
|
||||||
word = seedPhrase[i],
|
number = i + 1,
|
||||||
isDarkTheme = isDarkTheme,
|
word = seedPhrase[i],
|
||||||
visible = visible,
|
isDarkTheme = isDarkTheme,
|
||||||
delay = 300 + (i * 50)
|
visible = visible,
|
||||||
)
|
delay = i * 60
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
// Copy button
|
// Кнопка Copy
|
||||||
if (!isGenerating) {
|
AnimatedVisibility(
|
||||||
AnimatedVisibility(
|
visible = visible,
|
||||||
visible = visible,
|
enter = fadeIn(tween(400, delayMillis = 800)) + scaleIn(
|
||||||
enter = fadeIn(tween(500, delayMillis = 600)) + scaleIn(
|
initialScale = 0.8f,
|
||||||
initialScale = 0.8f,
|
animationSpec = tween(400, delayMillis = 800, easing = LinearOutSlowInEasing)
|
||||||
animationSpec = tween(500, delayMillis = 600)
|
)
|
||||||
)
|
) {
|
||||||
) {
|
TextButton(
|
||||||
TextButton(
|
onClick = {
|
||||||
onClick = {
|
clipboardManager.setText(AnnotatedString(seedPhrase.joinToString(" ")))
|
||||||
clipboardManager.setText(AnnotatedString(seedPhrase.joinToString(" ")))
|
hasCopied = true
|
||||||
hasCopied = true
|
scope.launch {
|
||||||
scope.launch {
|
delay(2000)
|
||||||
delay(2000)
|
hasCopied = false
|
||||||
hasCopied = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (hasCopied) Icons.Default.Check else Icons.Default.ContentCopy,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = if (hasCopied) "Copied" else "Copy to clipboard",
|
|
||||||
color = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue,
|
|
||||||
fontSize = 15.sp
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (hasCopied) Icons.Default.Check else Icons.Default.ContentCopy,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = if (hasCopied) "Copied" else "Copy to clipboard",
|
||||||
|
color = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue,
|
||||||
|
fontSize = 15.sp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
// Continue button
|
// Кнопка Continue
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(400, delayMillis = 700))
|
enter = fadeIn(tween(400, delayMillis = 900)) + slideInVertically(
|
||||||
|
initialOffsetY = { 20 },
|
||||||
|
animationSpec = tween(400, delayMillis = 900)
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = { onConfirm(seedPhrase) },
|
onClick = { onConfirm(seedPhrase) },
|
||||||
enabled = !isGenerating,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(50.dp),
|
.height(52.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = PrimaryBlue,
|
containerColor = PrimaryBlue,
|
||||||
contentColor = Color.White,
|
contentColor = Color.White
|
||||||
disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f),
|
|
||||||
disabledContentColor = Color.White.copy(alpha = 0.5f)
|
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(14.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Continue",
|
text = "Continue",
|
||||||
fontSize = 17.sp,
|
fontSize = 17.sp,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,21 +224,11 @@ private fun WordItem(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999)
|
val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999)
|
||||||
|
|
||||||
// Beautiful solid colors that fit the theme
|
|
||||||
val wordColors = listOf(
|
val wordColors = listOf(
|
||||||
Color(0xFF5E9FFF), // Soft blue
|
Color(0xFF5E9FFF), Color(0xFFFF7EB3), Color(0xFF7B68EE),
|
||||||
Color(0xFFFF7EB3), // Soft pink
|
Color(0xFF50C878), Color(0xFFFF6B6B), Color(0xFF4ECDC4),
|
||||||
Color(0xFF7B68EE), // Medium purple
|
Color(0xFFFFB347), Color(0xFFBA55D3), Color(0xFF87CEEB),
|
||||||
Color(0xFF50C878), // Emerald green
|
Color(0xFFDDA0DD), Color(0xFF98D8C8), Color(0xFFF7DC6F)
|
||||||
Color(0xFFFF6B6B), // Coral red
|
|
||||||
Color(0xFF4ECDC4), // Teal
|
|
||||||
Color(0xFFFFB347), // Pastel orange
|
|
||||||
Color(0xFFBA55D3), // Medium orchid
|
|
||||||
Color(0xFF87CEEB), // Sky blue
|
|
||||||
Color(0xFFDDA0DD), // Plum
|
|
||||||
Color(0xFF98D8C8), // Mint
|
|
||||||
Color(0xFFF7DC6F) // Soft yellow
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val wordColor = wordColors[(number - 1) % wordColors.size]
|
val wordColor = wordColors[(number - 1) % wordColors.size]
|
||||||
@@ -271,21 +239,18 @@ private fun WordItem(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.background(bgColor)
|
.background(bgColor)
|
||||||
.padding(horizontal = 16.dp, vertical = 14.dp)
|
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = "$number.",
|
text = "$number.",
|
||||||
fontSize = 15.sp,
|
fontSize = 13.sp,
|
||||||
color = numberColor,
|
color = numberColor,
|
||||||
modifier = Modifier.width(28.dp)
|
modifier = Modifier.width(26.dp)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = word,
|
text = word,
|
||||||
fontSize = 17.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = wordColor,
|
color = wordColor,
|
||||||
fontFamily = FontFamily.Monospace
|
fontFamily = FontFamily.Monospace
|
||||||
@@ -303,15 +268,21 @@ private fun AnimatedWordItem(
|
|||||||
delay: Int,
|
delay: Int,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
val overshootEasing = remember { CubicBezierEasing(0.175f, 0.885f, 0.32f, 1.275f) }
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(400, delayMillis = delay))
|
enter = fadeIn(animationSpec = tween(300, delayMillis = delay)) +
|
||||||
|
slideInVertically(
|
||||||
|
initialOffsetY = { 30 },
|
||||||
|
animationSpec = tween(400, delayMillis = delay, easing = FastOutSlowInEasing)
|
||||||
|
) +
|
||||||
|
scaleIn(
|
||||||
|
initialScale = 0.85f,
|
||||||
|
animationSpec = tween(400, delayMillis = delay, easing = overshootEasing)
|
||||||
|
),
|
||||||
|
modifier = modifier
|
||||||
) {
|
) {
|
||||||
WordItem(
|
WordItem(number, word, isDarkTheme)
|
||||||
number = number,
|
|
||||||
word = word,
|
|
||||||
isDarkTheme = isDarkTheme,
|
|
||||||
modifier = modifier
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -620,6 +620,50 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
updateCacheFromCurrentMessages()
|
updateCacheFromCurrentMessages()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Для исходящих media-сообщений (фото/файл/аватар) не держим "часики" после фактической отправки:
|
||||||
|
* optimistic WAITING в БД должен отображаться как SENT, если localUri уже очищен.
|
||||||
|
*/
|
||||||
|
private fun shouldTreatWaitingAsSent(entity: MessageEntity): Boolean {
|
||||||
|
if (entity.fromMe != 1 || entity.primaryAttachmentType < 0) return false
|
||||||
|
|
||||||
|
val attachments = parseAttachmentsJsonArray(entity.attachments) ?: return false
|
||||||
|
if (attachments.length() == 0) return false
|
||||||
|
|
||||||
|
var hasMediaAttachment = false
|
||||||
|
for (index in 0 until attachments.length()) {
|
||||||
|
val attachment = attachments.optJSONObject(index) ?: continue
|
||||||
|
when (parseAttachmentType(attachment)) {
|
||||||
|
AttachmentType.IMAGE,
|
||||||
|
AttachmentType.FILE,
|
||||||
|
AttachmentType.AVATAR -> {
|
||||||
|
hasMediaAttachment = true
|
||||||
|
if (attachment.optString("localUri", "").isNotBlank()) {
|
||||||
|
// Локальный URI ещё есть => загрузка/подготовка не завершена.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AttachmentType.UNKNOWN -> continue
|
||||||
|
else -> return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasMediaAttachment
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapEntityDeliveryStatus(entity: MessageEntity): MessageStatus {
|
||||||
|
return when (entity.delivered) {
|
||||||
|
DeliveryStatus.WAITING.value ->
|
||||||
|
if (shouldTreatWaitingAsSent(entity)) MessageStatus.SENT
|
||||||
|
else MessageStatus.SENDING
|
||||||
|
DeliveryStatus.DELIVERED.value ->
|
||||||
|
if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
|
||||||
|
DeliveryStatus.ERROR.value -> MessageStatus.ERROR
|
||||||
|
DeliveryStatus.READ.value -> MessageStatus.READ
|
||||||
|
else -> MessageStatus.SENT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun shortPhotoId(value: String, limit: Int = 8): String {
|
private fun shortPhotoId(value: String, limit: Int = 8): String {
|
||||||
val trimmed = value.trim()
|
val trimmed = value.trim()
|
||||||
if (trimmed.isEmpty()) return "unknown"
|
if (trimmed.isEmpty()) return "unknown"
|
||||||
@@ -1045,14 +1089,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
currentMessages.map { message ->
|
currentMessages.map { message ->
|
||||||
val entity = entitiesById[message.id] ?: return@map message
|
val entity = entitiesById[message.id] ?: return@map message
|
||||||
|
|
||||||
val dbStatus =
|
val dbStatus = mapEntityDeliveryStatus(entity)
|
||||||
when (entity.delivered) {
|
|
||||||
0 -> MessageStatus.SENDING
|
|
||||||
1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
|
|
||||||
2 -> MessageStatus.ERROR
|
|
||||||
3 -> MessageStatus.READ
|
|
||||||
else -> MessageStatus.SENT
|
|
||||||
}
|
|
||||||
|
|
||||||
var updatedMessage = message
|
var updatedMessage = message
|
||||||
if (updatedMessage.status != dbStatus) {
|
if (updatedMessage.status != dbStatus) {
|
||||||
@@ -1371,14 +1408,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
text = displayText,
|
text = displayText,
|
||||||
isOutgoing = entity.fromMe == 1,
|
isOutgoing = entity.fromMe == 1,
|
||||||
timestamp = Date(entity.timestamp),
|
timestamp = Date(entity.timestamp),
|
||||||
status =
|
status = mapEntityDeliveryStatus(entity),
|
||||||
when (entity.delivered) {
|
|
||||||
0 -> MessageStatus.SENDING
|
|
||||||
1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
|
|
||||||
2 -> MessageStatus.ERROR
|
|
||||||
3 -> MessageStatus.READ
|
|
||||||
else -> MessageStatus.SENT
|
|
||||||
},
|
|
||||||
replyData = if (forwardedMessages.isNotEmpty()) null else replyData,
|
replyData = if (forwardedMessages.isNotEmpty()) null else replyData,
|
||||||
forwardedMessages = forwardedMessages,
|
forwardedMessages = forwardedMessages,
|
||||||
attachments = finalAttachments,
|
attachments = finalAttachments,
|
||||||
@@ -3813,17 +3843,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
logPhotoPipeline(messageId, "db status+attachments updated")
|
logPhotoPipeline(messageId, "db status+attachments updated")
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (isSavedMessages) {
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
|
||||||
}
|
|
||||||
// Также очищаем localUri в UI
|
// Также очищаем localUri в UI
|
||||||
updateMessageAttachments(messageId, null)
|
updateMessageAttachments(messageId, null)
|
||||||
}
|
}
|
||||||
logPhotoPipeline(
|
logPhotoPipeline(messageId, "ui status switched to SENT")
|
||||||
messageId,
|
|
||||||
if (isSavedMessages) "ui status switched to SENT"
|
|
||||||
else "ui status kept at SENDING until delivery ACK"
|
|
||||||
)
|
|
||||||
|
|
||||||
saveDialog(
|
saveDialog(
|
||||||
lastMessage = if (caption.isNotEmpty()) caption else "photo",
|
lastMessage = if (caption.isNotEmpty()) caption else "photo",
|
||||||
@@ -4023,11 +4047,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
opponentPublicKey = recipient
|
opponentPublicKey = recipient
|
||||||
)
|
)
|
||||||
|
|
||||||
// Для обычных диалогов остаёмся в SENDING до PacketDelivery(messageId).
|
// После успешной отправки пакета фиксируем SENT (без ложного timeout->ERROR).
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (isSavedMessages) {
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
saveDialog(
|
saveDialog(
|
||||||
@@ -4311,9 +4333,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
_messages.value.map { msg ->
|
_messages.value.map { msg ->
|
||||||
if (msg.id != messageId) return@map msg
|
if (msg.id != messageId) return@map msg
|
||||||
msg.copy(
|
msg.copy(
|
||||||
status =
|
status = MessageStatus.SENT,
|
||||||
if (isSavedMessages) MessageStatus.SENT
|
|
||||||
else MessageStatus.SENDING,
|
|
||||||
attachments =
|
attachments =
|
||||||
msg.attachments.map { current ->
|
msg.attachments.map { current ->
|
||||||
val final = finalAttachmentsById[current.id]
|
val final = finalAttachmentsById[current.id]
|
||||||
@@ -4544,11 +4564,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
opponentPublicKey = recipient
|
opponentPublicKey = recipient
|
||||||
)
|
)
|
||||||
|
|
||||||
// Обновляем UI: для обычных чатов оставляем SENDING до PacketDelivery(messageId).
|
// После успешной отправки медиа переводим в SENT.
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (isSavedMessages) {
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
saveDialog(
|
saveDialog(
|
||||||
|
|||||||
@@ -610,6 +610,7 @@ fun ChatsListScreen(
|
|||||||
val topLevelChatsState by chatsViewModel.chatsState.collectAsState()
|
val topLevelChatsState by chatsViewModel.chatsState.collectAsState()
|
||||||
val topLevelIsLoading by chatsViewModel.isLoading.collectAsState()
|
val topLevelIsLoading by chatsViewModel.isLoading.collectAsState()
|
||||||
val topLevelRequestsCount = topLevelChatsState.requestsCount
|
val topLevelRequestsCount = topLevelChatsState.requestsCount
|
||||||
|
val visibleTopLevelRequestsCount = if (syncInProgress) 0 else topLevelRequestsCount
|
||||||
|
|
||||||
// Dev console dialog - commented out for now
|
// Dev console dialog - commented out for now
|
||||||
/*
|
/*
|
||||||
@@ -1163,7 +1164,7 @@ fun ChatsListScreen(
|
|||||||
text = "Requests",
|
text = "Requests",
|
||||||
iconColor = menuIconColor,
|
iconColor = menuIconColor,
|
||||||
textColor = menuTextColor,
|
textColor = menuTextColor,
|
||||||
badge = if (topLevelRequestsCount > 0) topLevelRequestsCount.toString() else null,
|
badge = if (visibleTopLevelRequestsCount > 0) visibleTopLevelRequestsCount.toString() else null,
|
||||||
badgeColor = accentColor,
|
badgeColor = accentColor,
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -1598,7 +1599,7 @@ fun ChatsListScreen(
|
|||||||
)
|
)
|
||||||
// Badge с числом запросов
|
// Badge с числом запросов
|
||||||
androidx.compose.animation.AnimatedVisibility(
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
visible = topLevelRequestsCount > 0,
|
visible = visibleTopLevelRequestsCount > 0,
|
||||||
enter = scaleIn(
|
enter = scaleIn(
|
||||||
animationSpec = spring(
|
animationSpec = spring(
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
@@ -1608,10 +1609,10 @@ fun ChatsListScreen(
|
|||||||
exit = scaleOut() + fadeOut(),
|
exit = scaleOut() + fadeOut(),
|
||||||
modifier = Modifier.align(Alignment.TopEnd)
|
modifier = Modifier.align(Alignment.TopEnd)
|
||||||
) {
|
) {
|
||||||
val badgeText = remember(topLevelRequestsCount) {
|
val badgeText = remember(visibleTopLevelRequestsCount) {
|
||||||
when {
|
when {
|
||||||
topLevelRequestsCount > 99 -> "99+"
|
visibleTopLevelRequestsCount > 99 -> "99+"
|
||||||
else -> topLevelRequestsCount.toString()
|
else -> visibleTopLevelRequestsCount.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val badgeBg = Color.White
|
val badgeBg = Color.White
|
||||||
@@ -1679,7 +1680,7 @@ fun ChatsListScreen(
|
|||||||
)
|
)
|
||||||
} else if (syncInProgress) {
|
} else if (syncInProgress) {
|
||||||
AnimatedDotsText(
|
AnimatedDotsText(
|
||||||
baseText = "Synchronizing",
|
baseText = "Updating",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 20.sp,
|
fontSize = 20.sp,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
@@ -1858,8 +1859,8 @@ fun ChatsListScreen(
|
|||||||
// независимо
|
// независимо
|
||||||
val chatsState = topLevelChatsState
|
val chatsState = topLevelChatsState
|
||||||
val isLoading = topLevelIsLoading
|
val isLoading = topLevelIsLoading
|
||||||
val requests = chatsState.requests
|
val requests = if (syncInProgress) emptyList() else chatsState.requests
|
||||||
val requestsCount = chatsState.requestsCount
|
val requestsCount = if (syncInProgress) 0 else chatsState.requestsCount
|
||||||
|
|
||||||
val showSkeleton by
|
val showSkeleton by
|
||||||
produceState(
|
produceState(
|
||||||
@@ -4118,6 +4119,7 @@ fun DialogItemContent(
|
|||||||
val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black }
|
val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black }
|
||||||
val secondaryTextColor =
|
val secondaryTextColor =
|
||||||
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) }
|
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) }
|
||||||
|
val visibleUnreadCount = if (syncInProgress) 0 else dialog.unreadCount
|
||||||
|
|
||||||
val isGroupDialog = remember(dialog.opponentKey) { isGroupDialogKey(dialog.opponentKey) }
|
val isGroupDialog = remember(dialog.opponentKey) { isGroupDialogKey(dialog.opponentKey) }
|
||||||
|
|
||||||
@@ -4450,7 +4452,7 @@ fun DialogItemContent(
|
|||||||
text = formattedTime,
|
text = formattedTime,
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
color =
|
color =
|
||||||
if (dialog.unreadCount > 0) PrimaryBlue
|
if (visibleUnreadCount > 0) PrimaryBlue
|
||||||
else secondaryTextColor
|
else secondaryTextColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4590,7 +4592,7 @@ fun DialogItemContent(
|
|||||||
baseDisplayText,
|
baseDisplayText,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color =
|
color =
|
||||||
if (dialog.unreadCount >
|
if (visibleUnreadCount >
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
textColor.copy(
|
textColor.copy(
|
||||||
@@ -4600,7 +4602,7 @@ fun DialogItemContent(
|
|||||||
else
|
else
|
||||||
secondaryTextColor,
|
secondaryTextColor,
|
||||||
fontWeight =
|
fontWeight =
|
||||||
if (dialog.unreadCount >
|
if (visibleUnreadCount >
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
FontWeight.Medium
|
FontWeight.Medium
|
||||||
@@ -4621,11 +4623,11 @@ fun DialogItemContent(
|
|||||||
text = baseDisplayText,
|
text = baseDisplayText,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color =
|
color =
|
||||||
if (dialog.unreadCount > 0)
|
if (visibleUnreadCount > 0)
|
||||||
textColor.copy(alpha = 0.85f)
|
textColor.copy(alpha = 0.85f)
|
||||||
else secondaryTextColor,
|
else secondaryTextColor,
|
||||||
fontWeight =
|
fontWeight =
|
||||||
if (dialog.unreadCount > 0)
|
if (visibleUnreadCount > 0)
|
||||||
FontWeight.Medium
|
FontWeight.Medium
|
||||||
else FontWeight.Normal,
|
else FontWeight.Normal,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -4660,15 +4662,15 @@ fun DialogItemContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unread badge
|
// Unread badge
|
||||||
if (dialog.unreadCount > 0) {
|
if (visibleUnreadCount > 0) {
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
val unreadText =
|
val unreadText =
|
||||||
remember(dialog.unreadCount) {
|
remember(visibleUnreadCount) {
|
||||||
when {
|
when {
|
||||||
dialog.unreadCount > 999 -> "999+"
|
visibleUnreadCount > 999 -> "999+"
|
||||||
dialog.unreadCount > 99 -> "99+"
|
visibleUnreadCount > 99 -> "99+"
|
||||||
else ->
|
else ->
|
||||||
dialog.unreadCount
|
visibleUnreadCount
|
||||||
.toString()
|
.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.rosetta.messenger.network.ProtocolManager
|
|||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
@@ -391,20 +392,31 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Подписываемся на обычные диалоги
|
// Подписываемся на обычные диалоги
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||||
launch {
|
launch {
|
||||||
dialogDao
|
dialogDao
|
||||||
.getDialogsFlow(publicKey)
|
.getDialogsFlow(publicKey)
|
||||||
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
|
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
|
||||||
.debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
|
.debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
|
||||||
.map { dialogsList ->
|
.combine(ProtocolManager.syncInProgress) { dialogsList, syncing ->
|
||||||
mapDialogListIncremental(
|
dialogsList to syncing
|
||||||
dialogsList = dialogsList,
|
|
||||||
privateKey = privateKey,
|
|
||||||
cache = dialogsUiCache,
|
|
||||||
isRequestsFlow = false
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
.mapLatest { (dialogsList, syncing) ->
|
||||||
|
// Desktop behavior parity:
|
||||||
|
// while sync is active we keep current chats list stable (no per-message UI churn),
|
||||||
|
// then apply one consolidated update when sync finishes.
|
||||||
|
if (syncing && _dialogs.value.isNotEmpty()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
mapDialogListIncremental(
|
||||||
|
dialogsList = dialogsList,
|
||||||
|
privateKey = privateKey,
|
||||||
|
cache = dialogsUiCache,
|
||||||
|
isRequestsFlow = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.filterNotNull()
|
||||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
||||||
.collect { decryptedDialogs ->
|
.collect { decryptedDialogs ->
|
||||||
_dialogs.value = decryptedDialogs
|
_dialogs.value = decryptedDialogs
|
||||||
@@ -423,19 +435,26 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 📬 Подписываемся на requests (запросы от новых пользователей)
|
// 📬 Подписываемся на requests (запросы от новых пользователей)
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||||
launch {
|
launch {
|
||||||
dialogDao
|
dialogDao
|
||||||
.getRequestsFlow(publicKey)
|
.getRequestsFlow(publicKey)
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
.debounce(100) // 🚀 Батчим быстрые обновления
|
.debounce(100) // 🚀 Батчим быстрые обновления
|
||||||
.map { requestsList ->
|
.combine(ProtocolManager.syncInProgress) { requestsList, syncing ->
|
||||||
mapDialogListIncremental(
|
requestsList to syncing
|
||||||
dialogsList = requestsList,
|
}
|
||||||
privateKey = privateKey,
|
.mapLatest { (requestsList, syncing) ->
|
||||||
cache = requestsUiCache,
|
if (syncing) {
|
||||||
isRequestsFlow = true
|
emptyList()
|
||||||
)
|
} else {
|
||||||
|
mapDialogListIncremental(
|
||||||
|
dialogsList = requestsList,
|
||||||
|
privateKey = privateKey,
|
||||||
|
cache = requestsUiCache,
|
||||||
|
isRequestsFlow = true
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
||||||
.collect { decryptedRequests -> _requests.value = decryptedRequests }
|
.collect { decryptedRequests -> _requests.value = decryptedRequests }
|
||||||
@@ -446,6 +465,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
dialogDao
|
dialogDao
|
||||||
.getRequestsCountFlow(publicKey)
|
.getRequestsCountFlow(publicKey)
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
|
.combine(ProtocolManager.syncInProgress) { count, syncing ->
|
||||||
|
if (syncing) 0 else count
|
||||||
|
}
|
||||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения
|
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения
|
||||||
.collect { count -> _requestsCount.value = count }
|
.collect { count -> _requestsCount.value = count }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
import com.rosetta.messenger.repository.AvatarRepository
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
@@ -39,12 +40,12 @@ fun RequestsListScreen(
|
|||||||
avatarRepository: AvatarRepository? = null
|
avatarRepository: AvatarRepository? = null
|
||||||
) {
|
) {
|
||||||
val chatsState by chatsViewModel.chatsState.collectAsState()
|
val chatsState by chatsViewModel.chatsState.collectAsState()
|
||||||
val requests = chatsState.requests
|
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
|
||||||
|
val requests = if (syncInProgress) emptyList() else chatsState.requests
|
||||||
val blockedUsers by chatsViewModel.blockedUsers.collectAsState()
|
val blockedUsers by chatsViewModel.blockedUsers.collectAsState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||||
val headerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)
|
val headerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -60,7 +61,7 @@ fun RequestsListScreen(
|
|||||||
},
|
},
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = "Requests",
|
text = if (syncInProgress) "Updating..." else "Requests",
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontSize = 20.sp,
|
fontSize = 20.sp,
|
||||||
color = Color.White
|
color = Color.White
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.*
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
@@ -238,14 +239,10 @@ fun SearchScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
detectHorizontalDragGestures { _, dragAmount ->
|
val hideKbScrollConnection = remember { HideKeyboardNestedScroll(view, focusManager) }
|
||||||
if (dragAmount > 10f) {
|
|
||||||
hideKeyboardInstantly()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(hideKbScrollConnection),
|
||||||
topBar = {
|
topBar = {
|
||||||
// Хедер как в Telegram: стрелка назад + поле ввода
|
// Хедер как в Telegram: стрелка назад + поле ввода
|
||||||
Surface(
|
Surface(
|
||||||
@@ -538,7 +535,7 @@ private fun ChatsTabContent(
|
|||||||
if (searchQuery.isEmpty()) {
|
if (searchQuery.isEmpty()) {
|
||||||
// ═══ Idle state: frequent contacts + recent searches ═══
|
// ═══ Idle state: frequent contacts + recent searches ═══
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize().imePadding()
|
||||||
) {
|
) {
|
||||||
// ─── Горизонтальный ряд частых контактов (как в Telegram) ───
|
// ─── Горизонтальный ряд частых контактов (как в Telegram) ───
|
||||||
if (visibleFrequentContacts.isNotEmpty()) {
|
if (visibleFrequentContacts.isNotEmpty()) {
|
||||||
@@ -1183,7 +1180,7 @@ private fun MessagesTabContent(
|
|||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize().imePadding(),
|
||||||
contentPadding = PaddingValues(vertical = 4.dp)
|
contentPadding = PaddingValues(vertical = 4.dp)
|
||||||
) {
|
) {
|
||||||
items(results, key = { it.messageId }) { result ->
|
items(results, key = { it.messageId }) { result ->
|
||||||
@@ -1757,7 +1754,7 @@ private fun DownloadsTabContent(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize().imePadding(),
|
||||||
contentPadding = PaddingValues(vertical = 4.dp)
|
contentPadding = PaddingValues(vertical = 4.dp)
|
||||||
) {
|
) {
|
||||||
items(files, key = { it.name }) { downloadedFile ->
|
items(files, key = { it.name }) { downloadedFile ->
|
||||||
@@ -1909,7 +1906,7 @@ private fun FilesTabContent(
|
|||||||
} else {
|
} else {
|
||||||
val dateFormat = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) }
|
val dateFormat = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) }
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize().imePadding(),
|
||||||
contentPadding = PaddingValues(vertical = 4.dp)
|
contentPadding = PaddingValues(vertical = 4.dp)
|
||||||
) {
|
) {
|
||||||
items(fileItems, key = { "${it.messageId}_${it.attachmentId}" }) { item ->
|
items(fileItems, key = { "${it.messageId}_${it.attachmentId}" }) { item ->
|
||||||
@@ -2163,3 +2160,21 @@ private fun RecentUserItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** NestedScrollConnection который скрывает клавиатуру при любом вертикальном скролле */
|
||||||
|
private class HideKeyboardNestedScroll(
|
||||||
|
private val view: android.view.View,
|
||||||
|
private val focusManager: androidx.compose.ui.focus.FocusManager
|
||||||
|
) : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
|
||||||
|
override fun onPreScroll(
|
||||||
|
available: androidx.compose.ui.geometry.Offset,
|
||||||
|
source: androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
|
): androidx.compose.ui.geometry.Offset {
|
||||||
|
if (kotlin.math.abs(available.y) > 0.5f) {
|
||||||
|
val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
return androidx.compose.ui.geometry.Offset.Zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -145,6 +145,42 @@ private fun shortDebugHash(bytes: ByteArray): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val LEGACY_ATTACHMENT_ERROR_TEXT =
|
||||||
|
"This attachment is no longer available because it was sent for a previous version of the app."
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LegacyAttachmentErrorCard(
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val borderColor = if (isDarkTheme) Color(0xFF2A2F38) else Color(0xFFE3E7EF)
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1E232B) else Color(0xFFF7F9FC)
|
||||||
|
val textColor = if (isDarkTheme) Color(0xFFE9EDF5) else Color(0xFF2B3340)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
modifier.fillMaxWidth()
|
||||||
|
.border(1.dp, borderColor, RoundedCornerShape(8.dp))
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(backgroundColor)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFFE55A5A),
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
text = LEGACY_ATTACHMENT_ERROR_TEXT,
|
||||||
|
color = textColor,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Анимированный текст с волнообразными точками.
|
* Анимированный текст с волнообразными точками.
|
||||||
* Три точки плавно подпрыгивают каскадом с изменением прозрачности.
|
* Три точки плавно подпрыгивают каскадом с изменением прозрачности.
|
||||||
@@ -538,7 +574,8 @@ fun MessageAttachments(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
/* MESSAGES обрабатываются отдельно */
|
// Desktop parity: unsupported/legacy attachment gets explicit compatibility card.
|
||||||
|
LegacyAttachmentErrorCard(isDarkTheme = isDarkTheme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -573,6 +573,7 @@ fun MessageBubble(
|
|||||||
val telegramIncomingAvatarSize = 42.dp
|
val telegramIncomingAvatarSize = 42.dp
|
||||||
val telegramIncomingAvatarLane = 48.dp
|
val telegramIncomingAvatarLane = 48.dp
|
||||||
val telegramIncomingAvatarInset = 6.dp
|
val telegramIncomingAvatarInset = 6.dp
|
||||||
|
val telegramIncomingBubbleGap = 6.dp
|
||||||
val shouldShowIncomingGroupAvatar =
|
val shouldShowIncomingGroupAvatar =
|
||||||
showIncomingGroupAvatar
|
showIncomingGroupAvatar
|
||||||
?: (isGroupChat &&
|
?: (isGroupChat &&
|
||||||
@@ -813,7 +814,13 @@ fun MessageBubble(
|
|||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.align(Alignment.Bottom)
|
Modifier.align(Alignment.Bottom)
|
||||||
.padding(end = 12.dp)
|
.padding(
|
||||||
|
start =
|
||||||
|
if (!message.isOutgoing && isGroupChat)
|
||||||
|
telegramIncomingBubbleGap
|
||||||
|
else 0.dp,
|
||||||
|
end = 12.dp
|
||||||
|
)
|
||||||
.then(bubbleWidthModifier)
|
.then(bubbleWidthModifier)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
this.alpha = selectionAlpha
|
this.alpha = selectionAlpha
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 2.0 MiB |
Reference in New Issue
Block a user