Фикс: поведение синхронизации, проработка UX, проработка UI в групповых чатах, проработка анимаций AuthFlow

This commit is contained in:
2026-04-07 03:00:54 +05:00
parent 081bdb6d30
commit 6d14881fa2
11 changed files with 287 additions and 213 deletions

View File

@@ -1590,8 +1590,7 @@ fun MainScreen(
isVisible = isSearchVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Search } },
isDarkTheme = isDarkTheme,
layer = 1,
deferToChildren = true
layer = 1
) {
// Экран поиска
SearchScreen(

View File

@@ -508,6 +508,7 @@ interface MessageDao {
WHERE account = :account
AND from_me = 1
AND delivered = 0
AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]')
AND timestamp >= :minTimestamp
ORDER BY timestamp ASC
"""
@@ -524,6 +525,7 @@ interface MessageDao {
WHERE account = :account
AND from_me = 1
AND delivered = 0
AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]')
AND timestamp < :maxTimestamp
"""
)

View File

@@ -12,7 +12,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -35,19 +34,18 @@ fun SeedPhraseScreen(
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
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 isGenerating by remember { mutableStateOf(true) }
var hasCopied by remember { mutableStateOf(false) }
var visible by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current
LaunchedEffect(Unit) {
delay(100)
// Генерируем фразу сразу, без задержек
seedPhrase = CryptoManager.generateSeedPhrase()
isGenerating = false
// Даем микро-паузу, чтобы верстка отрисовалась, и запускаем анимацию
delay(50)
visible = true
}
@@ -59,7 +57,7 @@ fun SeedPhraseScreen(
.navigationBarsPadding()
) {
Column(modifier = Modifier.fillMaxSize()) {
// Simple top bar
// Top bar
Row(
modifier = Modifier
.fillMaxWidth()
@@ -108,30 +106,13 @@ fun SeedPhraseScreen(
Spacer(modifier = Modifier.height(32.dp))
// Two column layout
if (isGenerating) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = PrimaryBlue,
strokeWidth = 2.dp,
modifier = Modifier.size(40.dp)
)
}
} else {
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(500, delayMillis = 200))
) {
// Сетка со словами (без Crossfade и лоадера)
if (seedPhrase.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Left column (words 1-6)
// Левая колонка (1-6)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp)
@@ -142,12 +123,12 @@ fun SeedPhraseScreen(
word = seedPhrase[i],
isDarkTheme = isDarkTheme,
visible = visible,
delay = 300 + (i * 50)
delay = i * 60
)
}
}
// Right column (words 7-12)
// Правая колонка (7-12)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp)
@@ -158,23 +139,21 @@ fun SeedPhraseScreen(
word = seedPhrase[i],
isDarkTheme = isDarkTheme,
visible = visible,
delay = 300 + (i * 50)
delay = i * 60
)
}
}
}
}
}
Spacer(modifier = Modifier.height(20.dp))
Spacer(modifier = Modifier.height(24.dp))
// Copy button
if (!isGenerating) {
// Кнопка Copy
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(500, delayMillis = 600)) + scaleIn(
enter = fadeIn(tween(400, delayMillis = 800)) + scaleIn(
initialScale = 0.8f,
animationSpec = tween(500, delayMillis = 600)
animationSpec = tween(400, delayMillis = 800, easing = LinearOutSlowInEasing)
)
) {
TextButton(
@@ -201,33 +180,32 @@ fun SeedPhraseScreen(
)
}
}
}
Spacer(modifier = Modifier.weight(1f))
// Continue button
// Кнопка Continue
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 700))
enter = fadeIn(tween(400, delayMillis = 900)) + slideInVertically(
initialOffsetY = { 20 },
animationSpec = tween(400, delayMillis = 900)
)
) {
Button(
onClick = { onConfirm(seedPhrase) },
enabled = !isGenerating,
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
.height(52.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White,
disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f),
disabledContentColor = Color.White.copy(alpha = 0.5f)
contentColor = Color.White
),
shape = RoundedCornerShape(12.dp)
shape = RoundedCornerShape(14.dp)
) {
Text(
text = "Continue",
fontSize = 17.sp,
fontWeight = FontWeight.Medium
fontWeight = FontWeight.SemiBold
)
}
}
@@ -246,21 +224,11 @@ private fun WordItem(
modifier: Modifier = Modifier
) {
val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999)
// Beautiful solid colors that fit the theme
val wordColors = listOf(
Color(0xFF5E9FFF), // Soft blue
Color(0xFFFF7EB3), // Soft pink
Color(0xFF7B68EE), // Medium purple
Color(0xFF50C878), // Emerald green
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
Color(0xFF5E9FFF), Color(0xFFFF7EB3), Color(0xFF7B68EE),
Color(0xFF50C878), Color(0xFFFF6B6B), Color(0xFF4ECDC4),
Color(0xFFFFB347), Color(0xFFBA55D3), Color(0xFF87CEEB),
Color(0xFFDDA0DD), Color(0xFF98D8C8), Color(0xFFF7DC6F)
)
val wordColor = wordColors[(number - 1) % wordColors.size]
@@ -271,21 +239,18 @@ private fun WordItem(
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(bgColor)
.padding(horizontal = 16.dp, vertical = 14.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "$number.",
fontSize = 15.sp,
fontSize = 13.sp,
color = numberColor,
modifier = Modifier.width(28.dp)
modifier = Modifier.width(26.dp)
)
Text(
text = word,
fontSize = 17.sp,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = wordColor,
fontFamily = FontFamily.Monospace
@@ -303,15 +268,21 @@ private fun AnimatedWordItem(
delay: Int,
modifier: Modifier = Modifier
) {
val overshootEasing = remember { CubicBezierEasing(0.175f, 0.885f, 0.32f, 1.275f) }
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = delay))
) {
WordItem(
number = number,
word = word,
isDarkTheme = isDarkTheme,
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(number, word, isDarkTheme)
}
}

View File

@@ -620,6 +620,50 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
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 {
val trimmed = value.trim()
if (trimmed.isEmpty()) return "unknown"
@@ -1045,14 +1089,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
currentMessages.map { message ->
val entity = entitiesById[message.id] ?: return@map message
val dbStatus =
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
}
val dbStatus = mapEntityDeliveryStatus(entity)
var updatedMessage = message
if (updatedMessage.status != dbStatus) {
@@ -1371,14 +1408,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
text = displayText,
isOutgoing = entity.fromMe == 1,
timestamp = Date(entity.timestamp),
status =
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
},
status = mapEntityDeliveryStatus(entity),
replyData = if (forwardedMessages.isNotEmpty()) null else replyData,
forwardedMessages = forwardedMessages,
attachments = finalAttachments,
@@ -3813,17 +3843,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
logPhotoPipeline(messageId, "db status+attachments updated")
withContext(Dispatchers.Main) {
if (isSavedMessages) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
// Также очищаем localUri в UI
updateMessageAttachments(messageId, null)
}
logPhotoPipeline(
messageId,
if (isSavedMessages) "ui status switched to SENT"
else "ui status kept at SENDING until delivery ACK"
)
logPhotoPipeline(messageId, "ui status switched to SENT")
saveDialog(
lastMessage = if (caption.isNotEmpty()) caption else "photo",
@@ -4023,12 +4047,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
opponentPublicKey = recipient
)
// Для обычных диалогов остаёмся в SENDING до PacketDelivery(messageId).
// После успешной отправки пакета фиксируем SENT (без ложного timeout->ERROR).
withContext(Dispatchers.Main) {
if (isSavedMessages) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
}
saveDialog(
lastMessage = if (text.isNotEmpty()) text else "photo",
@@ -4311,9 +4333,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
_messages.value.map { msg ->
if (msg.id != messageId) return@map msg
msg.copy(
status =
if (isSavedMessages) MessageStatus.SENT
else MessageStatus.SENDING,
status = MessageStatus.SENT,
attachments =
msg.attachments.map { current ->
val final = finalAttachmentsById[current.id]
@@ -4544,12 +4564,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
opponentPublicKey = recipient
)
// Обновляем UI: для обычных чатов оставляем SENDING до PacketDelivery(messageId).
// После успешной отправки медиа переводим в SENT.
withContext(Dispatchers.Main) {
if (isSavedMessages) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
}
saveDialog(
lastMessage = if (text.isNotEmpty()) text else "📷 ${images.size} photos",

View File

@@ -610,6 +610,7 @@ fun ChatsListScreen(
val topLevelChatsState by chatsViewModel.chatsState.collectAsState()
val topLevelIsLoading by chatsViewModel.isLoading.collectAsState()
val topLevelRequestsCount = topLevelChatsState.requestsCount
val visibleTopLevelRequestsCount = if (syncInProgress) 0 else topLevelRequestsCount
// Dev console dialog - commented out for now
/*
@@ -1163,7 +1164,7 @@ fun ChatsListScreen(
text = "Requests",
iconColor = menuIconColor,
textColor = menuTextColor,
badge = if (topLevelRequestsCount > 0) topLevelRequestsCount.toString() else null,
badge = if (visibleTopLevelRequestsCount > 0) visibleTopLevelRequestsCount.toString() else null,
badgeColor = accentColor,
onClick = {
scope.launch {
@@ -1598,7 +1599,7 @@ fun ChatsListScreen(
)
// Badge с числом запросов
androidx.compose.animation.AnimatedVisibility(
visible = topLevelRequestsCount > 0,
visible = visibleTopLevelRequestsCount > 0,
enter = scaleIn(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
@@ -1608,10 +1609,10 @@ fun ChatsListScreen(
exit = scaleOut() + fadeOut(),
modifier = Modifier.align(Alignment.TopEnd)
) {
val badgeText = remember(topLevelRequestsCount) {
val badgeText = remember(visibleTopLevelRequestsCount) {
when {
topLevelRequestsCount > 99 -> "99+"
else -> topLevelRequestsCount.toString()
visibleTopLevelRequestsCount > 99 -> "99+"
else -> visibleTopLevelRequestsCount.toString()
}
}
val badgeBg = Color.White
@@ -1679,7 +1680,7 @@ fun ChatsListScreen(
)
} else if (syncInProgress) {
AnimatedDotsText(
baseText = "Synchronizing",
baseText = "Updating",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
@@ -1858,8 +1859,8 @@ fun ChatsListScreen(
// независимо
val chatsState = topLevelChatsState
val isLoading = topLevelIsLoading
val requests = chatsState.requests
val requestsCount = chatsState.requestsCount
val requests = if (syncInProgress) emptyList() else chatsState.requests
val requestsCount = if (syncInProgress) 0 else chatsState.requestsCount
val showSkeleton by
produceState(
@@ -4118,6 +4119,7 @@ fun DialogItemContent(
val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black }
val secondaryTextColor =
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) }
@@ -4450,7 +4452,7 @@ fun DialogItemContent(
text = formattedTime,
fontSize = 13.sp,
color =
if (dialog.unreadCount > 0) PrimaryBlue
if (visibleUnreadCount > 0) PrimaryBlue
else secondaryTextColor
)
}
@@ -4590,7 +4592,7 @@ fun DialogItemContent(
baseDisplayText,
fontSize = 14.sp,
color =
if (dialog.unreadCount >
if (visibleUnreadCount >
0
)
textColor.copy(
@@ -4600,7 +4602,7 @@ fun DialogItemContent(
else
secondaryTextColor,
fontWeight =
if (dialog.unreadCount >
if (visibleUnreadCount >
0
)
FontWeight.Medium
@@ -4621,11 +4623,11 @@ fun DialogItemContent(
text = baseDisplayText,
fontSize = 14.sp,
color =
if (dialog.unreadCount > 0)
if (visibleUnreadCount > 0)
textColor.copy(alpha = 0.85f)
else secondaryTextColor,
fontWeight =
if (dialog.unreadCount > 0)
if (visibleUnreadCount > 0)
FontWeight.Medium
else FontWeight.Normal,
maxLines = 1,
@@ -4660,15 +4662,15 @@ fun DialogItemContent(
}
// Unread badge
if (dialog.unreadCount > 0) {
if (visibleUnreadCount > 0) {
Spacer(modifier = Modifier.width(8.dp))
val unreadText =
remember(dialog.unreadCount) {
remember(visibleUnreadCount) {
when {
dialog.unreadCount > 999 -> "999+"
dialog.unreadCount > 99 -> "99+"
visibleUnreadCount > 999 -> "999+"
visibleUnreadCount > 99 -> "99+"
else ->
dialog.unreadCount
visibleUnreadCount
.toString()
}
}

View File

@@ -16,6 +16,7 @@ import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
@@ -391,13 +392,22 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
// Подписываемся на обычные диалоги
@OptIn(FlowPreview::class)
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
launch {
dialogDao
.getDialogsFlow(publicKey)
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
.debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
.map { dialogsList ->
.combine(ProtocolManager.syncInProgress) { dialogsList, syncing ->
dialogsList to syncing
}
.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,
@@ -405,6 +415,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
isRequestsFlow = false
)
}
}
.filterNotNull()
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedDialogs ->
_dialogs.value = decryptedDialogs
@@ -423,13 +435,19 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
// 📬 Подписываемся на requests (запросы от новых пользователей)
@OptIn(FlowPreview::class)
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
launch {
dialogDao
.getRequestsFlow(publicKey)
.flowOn(Dispatchers.IO)
.debounce(100) // 🚀 Батчим быстрые обновления
.map { requestsList ->
.combine(ProtocolManager.syncInProgress) { requestsList, syncing ->
requestsList to syncing
}
.mapLatest { (requestsList, syncing) ->
if (syncing) {
emptyList()
} else {
mapDialogListIncremental(
dialogsList = requestsList,
privateKey = privateKey,
@@ -437,6 +455,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
isRequestsFlow = true
)
}
}
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedRequests -> _requests.value = decryptedRequests }
}
@@ -446,6 +465,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
dialogDao
.getRequestsCountFlow(publicKey)
.flowOn(Dispatchers.IO)
.combine(ProtocolManager.syncInProgress) { count, syncing ->
if (syncing) 0 else count
}
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения
.collect { count -> _requestsCount.value = count }
}

View File

@@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
@@ -39,12 +40,12 @@ fun RequestsListScreen(
avatarRepository: AvatarRepository? = null
) {
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 scope = rememberCoroutineScope()
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val headerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)
val textColor = if (isDarkTheme) Color.White else Color.Black
Scaffold(
topBar = {
@@ -60,7 +61,7 @@ fun RequestsListScreen(
},
title = {
Text(
text = "Requests",
text = if (syncInProgress) "Updating..." else "Requests",
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = Color.White

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -238,14 +239,10 @@ fun SearchScreen(
}
}
Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) {
detectHorizontalDragGestures { _, dragAmount ->
if (dragAmount > 10f) {
hideKeyboardInstantly()
}
}
}) {
Box(modifier = Modifier.fillMaxSize()) {
val hideKbScrollConnection = remember { HideKeyboardNestedScroll(view, focusManager) }
Scaffold(
modifier = Modifier.nestedScroll(hideKbScrollConnection),
topBar = {
// Хедер как в Telegram: стрелка назад + поле ввода
Surface(
@@ -538,7 +535,7 @@ private fun ChatsTabContent(
if (searchQuery.isEmpty()) {
// ═══ Idle state: frequent contacts + recent searches ═══
LazyColumn(
modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize().imePadding()
) {
// ─── Горизонтальный ряд частых контактов (как в Telegram) ───
if (visibleFrequentContacts.isNotEmpty()) {
@@ -1183,7 +1180,7 @@ private fun MessagesTabContent(
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().imePadding(),
contentPadding = PaddingValues(vertical = 4.dp)
) {
items(results, key = { it.messageId }) { result ->
@@ -1757,7 +1754,7 @@ private fun DownloadsTabContent(
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().imePadding(),
contentPadding = PaddingValues(vertical = 4.dp)
) {
items(files, key = { it.name }) { downloadedFile ->
@@ -1909,7 +1906,7 @@ private fun FilesTabContent(
} else {
val dateFormat = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().imePadding(),
contentPadding = PaddingValues(vertical = 4.dp)
) {
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
}
}

View File

@@ -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 -> {
/* MESSAGES обрабатываются отдельно */
// Desktop parity: unsupported/legacy attachment gets explicit compatibility card.
LegacyAttachmentErrorCard(isDarkTheme = isDarkTheme)
}
}
}

View File

@@ -573,6 +573,7 @@ fun MessageBubble(
val telegramIncomingAvatarSize = 42.dp
val telegramIncomingAvatarLane = 48.dp
val telegramIncomingAvatarInset = 6.dp
val telegramIncomingBubbleGap = 6.dp
val shouldShowIncomingGroupAvatar =
showIncomingGroupAvatar
?: (isGroupChat &&
@@ -813,7 +814,13 @@ fun MessageBubble(
Box(
modifier =
Modifier.align(Alignment.Bottom)
.padding(end = 12.dp)
.padding(
start =
if (!message.isOutgoing && isGroupChat)
telegramIncomingBubbleGap
else 0.dp,
end = 12.dp
)
.then(bubbleWidthModifier)
.graphicsLayer {
this.alpha = selectionAlpha

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB