Релиз 1.4.5: стабилизация звонков, фиксы UI
All checks were successful
Android Kernel Build / build (push) Successful in 19m24s

- Звонок не сбрасывается при переподключении WebSocket
- Убрано мелькание "Unknown" при завершении (флаг resetting)
- Фикс placeholderColor в ChatDetailScreen (release build)
- ReleaseNotes.kt обновлён с детальным описанием всех изменений
This commit is contained in:
2026-04-04 15:52:54 +05:00
parent 6886a6cef1
commit 7d4b9a8fc4
8 changed files with 150 additions and 205 deletions

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release // Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.4.4" val rosettaVersionName = "1.4.5"
val rosettaVersionCode = 46 // Increment on each release val rosettaVersionCode = 47 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar") val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android { android {

View File

@@ -1122,18 +1122,13 @@ fun MainScreen(
accountName = accountName, accountName = accountName,
accountUsername = accountUsername, accountUsername = accountUsername,
accountVerified = accountVerified, accountVerified = accountVerified,
accountPhone = accountPhone,
accountPublicKey = accountPublicKey, accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey, accountPrivateKey = accountPrivateKey,
privateKeyHash = privateKeyHash,
onToggleTheme = onToggleTheme, onToggleTheme = onToggleTheme,
onProfileClick = { pushScreen(Screen.Profile) }, onProfileClick = { pushScreen(Screen.Profile) },
onNewGroupClick = { onNewGroupClick = {
pushScreen(Screen.GroupSetup) pushScreen(Screen.GroupSetup)
}, },
onContactsClick = {
// TODO: Navigate to contacts
},
onCallsClick = { onCallsClick = {
// TODO: Navigate to calls // TODO: Navigate to calls
}, },
@@ -1152,9 +1147,6 @@ fun MainScreen(
) )
}, },
onSettingsClick = { pushScreen(Screen.Profile) }, onSettingsClick = { pushScreen(Screen.Profile) },
onInviteFriendsClick = {
// TODO: Share invite link
},
onSearchClick = { pushScreen(Screen.Search) }, onSearchClick = { pushScreen(Screen.Search) },
onRequestsClick = { pushScreen(Screen.Requests) }, onRequestsClick = { pushScreen(Screen.Requests) },
onNewChat = { onNewChat = {
@@ -1166,7 +1158,6 @@ fun MainScreen(
onStartCall = { user -> onStartCall = { user ->
startCallWithPermission(user) startCallWithPermission(user)
}, },
backgroundBlurColorId = backgroundBlurColorId,
pinnedChats = pinnedChats, pinnedChats = pinnedChats,
onTogglePin = { opponentKey -> onTogglePin = { opponentKey ->
mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) } mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) }

View File

@@ -18,15 +18,31 @@ object ReleaseNotes {
Update v$VERSION_PLACEHOLDER Update v$VERSION_PLACEHOLDER
Звонки Звонки
- Обновлён протокол WebRTC: publicKey и deviceId в каждом пакете - Полноэкранный входящий звонок (IncomingCallActivity) поверх экрана блокировки с кнопками Принять/Отклонить
- Полноэкранный входящий звонок на экране блокировки - Обновлён протокол WebRTC: publicKey и deviceId в каждом пакете (совместимость с новым сервером)
- Фикс бесконечного "Exchanging keys" при принятии звонка - Звонок больше не сбрасывается при переподключении WebSocket
- Фикс краша ForegroundService при исходящем звонке - Исправлен бесконечный статус "Exchanging keys" — KEY_EXCHANGE отправляется с ретраем до 6 сек
- Кастомный WebRTC с E2EE теперь работает в CI-сборках - Автоматическая привязка аккаунта при принятии звонка из push-уведомления
- Исправлен краш ForegroundService при исходящем звонке (safeStopForeground)
- Убрано мелькание "Unknown" при завершении звонка
- Кнопка Decline теперь работает во всех фазах звонка
- Баннер активного звонка теперь отображается внутри диалога
- Дедупликация push + WebSocket сигналов (без мерцания уведомлений)
- Защита от фантомных звонков при принятии на другом устройстве
- Корректное освобождение PeerConnection (dispose) при завершении звонка
- Кастомный WebRTC AAR с E2EE добавлен в репозиторий для CI-сборок
- Диагностические логи звонков и уведомлений в rosettadev1
Уведомления Уведомления
- Аватарки и имена в уведомлениях - Аватарки и имена пользователей в уведомлениях о сообщениях и звонках
- Настройка отключения аватарок в уведомлениях - Настройка включения/выключения аватарок в уведомлениях (Notifications → Avatars in Notifications)
- Сохранение FCM токена в rosettadev1 для диагностики
- Поддержка tokenType и deviceId в push-подписке
Интерфейс
- Ограничение масштаба шрифта до 1.3x — вёрстка не ломается на телефонах с огромным текстом
- Новые обои: Light 1-3 для светлой темы, Dark 1-3 для тёмной темы
- Убраны старые обои, исправлено растяжение превью обоев
""".trimIndent() """.trimIndent()
fun getNotice(version: String): String = fun getNotice(version: String): String =

View File

@@ -368,9 +368,6 @@ fun ChatDetailScreen(
// 🎨 Window reference для управления статус баром // 🎨 Window reference для управления статус баром
val window = remember { (view.context as? Activity)?.window } val window = remember { (view.context as? Activity)?.window }
// 🔥 Focus state for input
val inputFocusRequester = remember { FocusRequester() }
// 🔥 Emoji picker state // 🔥 Emoji picker state
var showEmojiPicker by remember { mutableStateOf(false) } var showEmojiPicker by remember { mutableStateOf(false) }
@@ -539,7 +536,7 @@ fun ChatDetailScreen(
} else { } else {
val isOverlayControllingSystemBars = showMediaPicker val isOverlayControllingSystemBars = showMediaPicker
if (!isOverlayControllingSystemBars && window != null && view != null) { if (!isOverlayControllingSystemBars && window != null) {
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view) val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
window.statusBarColor = android.graphics.Color.TRANSPARENT window.statusBarColor = android.graphics.Color.TRANSPARENT
ic.isAppearanceLightStatusBars = false ic.isAppearanceLightStatusBars = false
@@ -557,7 +554,7 @@ fun ChatDetailScreen(
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
// Восстановить белые иконки статус-бара для chat list header // Восстановить белые иконки статус-бара для chat list header
if (window != null && view != null) { if (window != null) {
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view) val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
window.statusBarColor = android.graphics.Color.TRANSPARENT window.statusBarColor = android.graphics.Color.TRANSPARENT
ic.isAppearanceLightStatusBars = false ic.isAppearanceLightStatusBars = false
@@ -571,9 +568,6 @@ fun ChatDetailScreen(
} }
} }
// 📷 Camera: URI для сохранения фото
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
// 📷 Состояние для flow камеры: фото → редактор с caption → отправка // 📷 Состояние для flow камеры: фото → редактор с caption → отправка
var pendingCameraPhotoUri by remember { var pendingCameraPhotoUri by remember {
mutableStateOf<Uri?>(null) mutableStateOf<Uri?>(null)
@@ -648,47 +642,6 @@ fun ChatDetailScreen(
onDispose { onImageViewerChanged(false) } onDispose { onImageViewerChanged(false) }
} }
// <20>📷 Camera launcher
val cameraLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { success ->
if (success && cameraImageUri != null) {
// Очищаем фокус чтобы клавиатура не появилась
keyboardController?.hide()
focusManager.clearFocus()
// Открываем редактор вместо прямой отправки
pendingCameraPhotoUri = cameraImageUri
}
}
// <20> Gallery-as-file launcher (sends images as compressed files, not as photos)
val galleryAsFileLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri ->
if (uri != null) {
scope.launch {
val fileName = MediaUtils.getFileName(context, uri)
val fileSize = MediaUtils.getFileSize(context, uri)
if (fileSize > MediaUtils.MAX_FILE_SIZE_MB * 1024 * 1024) {
android.widget.Toast.makeText(
context,
"File too large (max ${MediaUtils.MAX_FILE_SIZE_MB} MB)",
android.widget.Toast.LENGTH_LONG
).show()
return@launch
}
val base64 = MediaUtils.uriToBase64File(context, uri)
if (base64 != null) {
viewModel.sendFileMessage(base64, fileName, fileSize)
}
}
}
}
// <20>📄 File picker launcher // <20>📄 File picker launcher
val filePickerLauncher = val filePickerLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
@@ -827,7 +780,6 @@ fun ChatDetailScreen(
// Подключаем к ViewModel // Подключаем к ViewModel
val messages by viewModel.messages.collectAsState() val messages by viewModel.messages.collectAsState()
val inputText by viewModel.inputText.collectAsState()
val isTyping by viewModel.opponentTyping.collectAsState() val isTyping by viewModel.opponentTyping.collectAsState()
val typingDisplayName by viewModel.typingDisplayName.collectAsState() val typingDisplayName by viewModel.typingDisplayName.collectAsState()
val typingDisplayPublicKey by viewModel.typingDisplayPublicKey.collectAsState() val typingDisplayPublicKey by viewModel.typingDisplayPublicKey.collectAsState()
@@ -2704,66 +2656,40 @@ fun ChatDetailScreen(
} else if (!isSystemAccount) { } else if (!isSystemAccount) {
// INPUT BAR // INPUT BAR
Column { Column {
MessageInputBar( ChatInputBarSection(
value = inputText, viewModel = viewModel,
onValueChange = { isSavedMessages = isSavedMessages,
viewModel
.updateInputText(
it
)
if (it.isNotEmpty() &&
!isSavedMessages
) {
viewModel
.sendTypingIndicator()
}
},
onSend = { onSend = {
isSendingMessage = isSendingMessage = true
true viewModel.sendMessage()
viewModel
.sendMessage()
scope.launch { scope.launch {
delay(100) delay(100)
listState listState.animateScrollToItem(0)
.animateScrollToItem(
0
)
delay(300) delay(300)
isSendingMessage = isSendingMessage = false
false
} }
}, },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
backgroundColor = backgroundColor = backgroundColor,
backgroundColor,
textColor = textColor, textColor = textColor,
placeholderColor =
secondaryTextColor,
secondaryTextColor = secondaryTextColor =
secondaryTextColor, secondaryTextColor,
replyMessages = replyMessages = replyMessages,
replyMessages, isForwardMode = isForwardMode,
isForwardMode =
isForwardMode,
onCloseReply = { onCloseReply = {
viewModel viewModel.clearReplyMessages()
.clearReplyMessages()
}, },
onShowForwardOptions = { panelMessages -> onShowForwardOptions = { panelMessages ->
if (panelMessages.isEmpty()) { if (panelMessages.isEmpty()) {
return@MessageInputBar return@ChatInputBarSection
} }
val forwardMessages = val forwardMessages =
panelMessages.map { msg -> panelMessages.map { msg ->
ForwardManager.ForwardMessage( ForwardManager.ForwardMessage(
messageId = messageId = msg.messageId,
msg.messageId,
text = msg.text, text = msg.text,
timestamp = timestamp = msg.timestamp,
msg.timestamp, isOutgoing = msg.isOutgoing,
isOutgoing =
msg.isOutgoing,
senderPublicKey = senderPublicKey =
msg.publicKey.ifEmpty { msg.publicKey.ifEmpty {
if (msg.isOutgoing) currentUserPublicKey if (msg.isOutgoing) currentUserPublicKey
@@ -2793,43 +2719,28 @@ fun ChatDetailScreen(
}, },
chatTitle = chatTitle, chatTitle = chatTitle,
isBlocked = isBlocked, isBlocked = isBlocked,
showEmojiPicker = showEmojiPicker = showEmojiPicker,
showEmojiPicker,
onToggleEmojiPicker = { onToggleEmojiPicker = {
showEmojiPicker = it showEmojiPicker = it
}, },
focusRequester =
inputFocusRequester,
coordinator = coordinator, coordinator = coordinator,
displayReplyMessages = displayReplyMessages =
displayReplyMessages, displayReplyMessages,
onReplyClick = onReplyClick = scrollToMessage,
scrollToMessage,
onAttachClick = { onAttachClick = {
// Telegram-style: // Telegram-style:
// галерея // галерея открывается поверх клавиатуры.
// открывается showMediaPicker = true
// ПОВЕРХ клавиатуры
// НЕ скрываем
// клавиатуру!
showMediaPicker =
true
}, },
myPublicKey = myPublicKey =
viewModel viewModel.myPublicKey ?: "",
.myPublicKey opponentPublicKey = user.publicKey,
?: "", myPrivateKey = currentUserPrivateKey,
opponentPublicKey =
user.publicKey,
myPrivateKey =
currentUserPrivateKey,
isGroupChat = isGroupChat, isGroupChat = isGroupChat,
mentionCandidates = mentionCandidates, mentionCandidates = mentionCandidates,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
inputFocusTrigger = inputFocusTrigger = inputFocusTrigger,
inputFocusTrigger, suppressKeyboard = showInAppCamera,
suppressKeyboard =
showInAppCamera,
hasNativeNavigationBar = hasNativeNavigationBar =
hasNativeNavigationBar hasNativeNavigationBar
) )
@@ -4311,6 +4222,76 @@ fun ChatDetailScreen(
} // Закрытие outer Box } // Закрытие outer Box
} }
@Composable
private fun ChatInputBarSection(
viewModel: ChatViewModel,
isSavedMessages: Boolean,
onSend: () -> Unit,
isDarkTheme: Boolean,
backgroundColor: Color,
textColor: Color,
secondaryTextColor: Color,
replyMessages: List<ChatViewModel.ReplyMessage>,
isForwardMode: Boolean,
onCloseReply: () -> Unit,
onShowForwardOptions: (List<ChatViewModel.ReplyMessage>) -> Unit,
chatTitle: String,
isBlocked: Boolean,
showEmojiPicker: Boolean,
onToggleEmojiPicker: (Boolean) -> Unit,
coordinator: KeyboardTransitionCoordinator,
displayReplyMessages: List<ChatViewModel.ReplyMessage>,
onReplyClick: (String) -> Unit,
onAttachClick: () -> Unit,
myPublicKey: String,
opponentPublicKey: String,
myPrivateKey: String,
isGroupChat: Boolean,
mentionCandidates: List<MentionCandidate>,
avatarRepository: AvatarRepository?,
inputFocusTrigger: Int,
suppressKeyboard: Boolean,
hasNativeNavigationBar: Boolean
) {
val inputText by viewModel.inputText.collectAsState()
MessageInputBar(
value = inputText,
onValueChange = {
viewModel.updateInputText(it)
if (it.isNotEmpty() && !isSavedMessages) {
viewModel.sendTypingIndicator()
}
},
onSend = onSend,
isDarkTheme = isDarkTheme,
backgroundColor = backgroundColor,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
replyMessages = replyMessages,
isForwardMode = isForwardMode,
onCloseReply = onCloseReply,
onShowForwardOptions = onShowForwardOptions,
chatTitle = chatTitle,
isBlocked = isBlocked,
showEmojiPicker = showEmojiPicker,
onToggleEmojiPicker = onToggleEmojiPicker,
coordinator = coordinator,
displayReplyMessages = displayReplyMessages,
onReplyClick = onReplyClick,
onAttachClick = onAttachClick,
myPublicKey = myPublicKey,
opponentPublicKey = opponentPublicKey,
myPrivateKey = myPrivateKey,
isGroupChat = isGroupChat,
mentionCandidates = mentionCandidates,
avatarRepository = avatarRepository,
inputFocusTrigger = inputFocusTrigger,
suppressKeyboard = suppressKeyboard,
hasNativeNavigationBar = hasNativeNavigationBar
)
}
@Composable @Composable
private fun GroupMembersSubtitleSkeleton() { private fun GroupMembersSubtitleSkeleton() {
val transition = rememberInfiniteTransition(label = "groupMembersSkeleton") val transition = rememberInfiniteTransition(label = "groupMembersSkeleton")

View File

@@ -264,24 +264,19 @@ fun ChatsListScreen(
accountName: String, accountName: String,
accountUsername: String, accountUsername: String,
accountVerified: Int = 0, accountVerified: Int = 0,
accountPhone: String,
accountPublicKey: String, accountPublicKey: String,
accountPrivateKey: String = "", accountPrivateKey: String = "",
privateKeyHash: String = "",
onToggleTheme: () -> Unit, onToggleTheme: () -> Unit,
onProfileClick: () -> Unit, onProfileClick: () -> Unit,
onNewGroupClick: () -> Unit, onNewGroupClick: () -> Unit,
onContactsClick: () -> Unit,
onCallsClick: () -> Unit, onCallsClick: () -> Unit,
onSavedMessagesClick: () -> Unit, onSavedMessagesClick: () -> Unit,
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit,
onInviteFriendsClick: () -> Unit,
onSearchClick: () -> Unit, onSearchClick: () -> Unit,
onRequestsClick: () -> Unit = {}, onRequestsClick: () -> Unit = {},
onNewChat: () -> Unit, onNewChat: () -> Unit,
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {}, onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
onStartCall: (com.rosetta.messenger.network.SearchUser) -> Unit = {}, onStartCall: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
backgroundBlurColorId: String = "avatar",
pinnedChats: Set<String> = emptySet(), pinnedChats: Set<String> = emptySet(),
onTogglePin: (String) -> Unit = {}, onTogglePin: (String) -> Unit = {},
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
@@ -462,7 +457,6 @@ fun ChatsListScreen(
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих // 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
// сообщений // сообщений
val initStart = System.currentTimeMillis()
ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey) ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey)
android.util.Log.d( android.util.Log.d(
"ChatsListScreen", "ChatsListScreen",
@@ -569,10 +563,6 @@ fun ChatsListScreen(
allAccounts = accounts.sortedByDescending { it.publicKey == accountPublicKey } allAccounts = accounts.sortedByDescending { it.publicKey == accountPublicKey }
} }
// 🔥 Используем rememberSaveable чтобы сохранить состояние при навигации
// Header сразу visible = true, без анимации при возврате из чата
var visible by rememberSaveable { mutableStateOf(true) }
// Confirmation dialogs state // Confirmation dialogs state
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) } var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
var dialogToLeave by remember { mutableStateOf<DialogUiModel?>(null) } var dialogToLeave by remember { mutableStateOf<DialogUiModel?>(null) }
@@ -794,13 +784,6 @@ fun ChatsListScreen(
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 🎨 DRAWER HEADER // 🎨 DRAWER HEADER
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val avatarColors =
getAvatarColor(
accountPublicKey,
isDarkTheme
)
val headerColor = avatarColors.backgroundColor
// Header: цвет шапки сайдбара // Header: цвет шапки сайдбара
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
Box( Box(
@@ -3462,9 +3445,6 @@ fun ChatItem(
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
val avatarColors = getAvatarColor(chat.publicKey, isDarkTheme)
val avatarText = getAvatarText(chat.publicKey)
Column { Column {
Row( Row(
modifier = modifier =
@@ -3939,13 +3919,11 @@ fun SwipeableDialogItem(
velocityTracker.resetTracking() velocityTracker.resetTracking()
var totalDragX = 0f var totalDragX = 0f
var totalDragY = 0f var totalDragY = 0f
var passedSlop = false
var claimed = false var claimed = false
// Phase 1: Determine gesture type (tap / long-press / drag) // Phase 1: Determine gesture type (tap / long-press / drag)
// Wait up to longPressTimeout; if no up or slop → long press // Wait up to longPressTimeout; if no up or slop → long press
var gestureType = "unknown" var gestureType = "unknown"
var fingerIsUp = false
val result = withTimeoutOrNull(longPressTimeoutMs) { val result = withTimeoutOrNull(longPressTimeoutMs) {
while (true) { while (true) {
@@ -3982,19 +3960,17 @@ fun SwipeableDialogItem(
// Timeout → check if finger lifted during the race window // Timeout → check if finger lifted during the race window
if (result == null) { if (result == null) {
// Grace period: check if up event arrived just as timeout fired // Grace period: check if up event arrived just as timeout fired
val graceResult = withTimeoutOrNull(32L) { withTimeoutOrNull(32L) {
while (true) { while (true) {
val event = awaitPointerEvent() val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } val change = event.changes.firstOrNull { it.id == down.id }
if (change == null) { if (change == null) {
gestureType = "cancelled" gestureType = "cancelled"
fingerIsUp = true
return@withTimeoutOrNull Unit return@withTimeoutOrNull Unit
} }
if (change.changedToUpIgnoreConsumed()) { if (change.changedToUpIgnoreConsumed()) {
change.consume() change.consume()
gestureType = "tap" gestureType = "tap"
fingerIsUp = true
return@withTimeoutOrNull Unit return@withTimeoutOrNull Unit
} }
// Still moving/holding — it's a real long press // Still moving/holding — it's a real long press
@@ -4034,13 +4010,11 @@ fun SwipeableDialogItem(
when { when {
// Horizontal left swipe — reveal action buttons // Horizontal left swipe — reveal action buttons
currentSwipeEnabled && dominated && totalDragX < 0 -> { currentSwipeEnabled && dominated && totalDragX < 0 -> {
passedSlop = true
claimed = true claimed = true
currentOnSwipeStarted() currentOnSwipeStarted()
} }
// Horizontal right swipe with buttons open — close them // Horizontal right swipe with buttons open — close them
dominated && totalDragX > 0 && offsetX != 0f -> { dominated && totalDragX > 0 && offsetX != 0f -> {
passedSlop = true
claimed = true claimed = true
} }
// Right swipe with buttons closed — let drawer handle // Right swipe with buttons closed — let drawer handle
@@ -4144,10 +4118,6 @@ fun DialogItemContent(
val secondaryTextColor = val secondaryTextColor =
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) } remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) }
val avatarColors =
remember(dialog.opponentKey, isDarkTheme) {
getAvatarColor(dialog.opponentKey, isDarkTheme)
}
val isGroupDialog = remember(dialog.opponentKey) { isGroupDialogKey(dialog.opponentKey) } val isGroupDialog = remember(dialog.opponentKey) { isGroupDialogKey(dialog.opponentKey) }
// 📁 Для Saved Messages показываем специальное имя // 📁 Для Saved Messages показываем специальное имя
@@ -4182,38 +4152,6 @@ fun DialogItemContent(
} }
} }
// 📁 Для Saved Messages показываем иконку закладки
// 🔥 Как в Архиве: инициалы из title или username или DELETED
val initials =
remember(
dialog.opponentTitle,
dialog.opponentUsername,
dialog.opponentKey,
dialog.isSavedMessages
) {
if (dialog.isSavedMessages) {
"" // Для Saved Messages - пустая строка, будет использоваться
// иконка
} else if (dialog.opponentTitle.isNotEmpty() &&
dialog.opponentTitle != dialog.opponentKey &&
dialog.opponentTitle != dialog.opponentKey.take(7) &&
dialog.opponentTitle != dialog.opponentKey.take(8)
) {
// Используем title для инициалов
dialog.opponentTitle
.split(" ")
.take(2)
.mapNotNull { it.firstOrNull()?.uppercase() }
.joinToString("")
.ifEmpty { dialog.opponentTitle.take(2).uppercase() }
} else if (dialog.opponentUsername.isNotEmpty()) {
// Если только username - берем первые 2 символа
dialog.opponentUsername.take(2).uppercase()
} else {
dialog.opponentKey.take(2).uppercase()
}
}
Row( Row(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
@@ -5003,7 +4941,6 @@ private fun RequestsRouteContent(
RequestsScreen( RequestsScreen(
requests = requests, requests = requests,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onBack = onBack,
onRequestClick = onRequestClick, onRequestClick = onRequestClick,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
blockedUsers = blockedUsers, blockedUsers = blockedUsers,
@@ -5138,7 +5075,6 @@ fun RequestsSection(
fun RequestsScreen( fun RequestsScreen(
requests: List<DialogUiModel>, requests: List<DialogUiModel>,
isDarkTheme: Boolean, isDarkTheme: Boolean,
onBack: () -> Unit,
onRequestClick: (DialogUiModel) -> Unit, onRequestClick: (DialogUiModel) -> Unit,
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null, avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
blockedUsers: Set<String> = emptySet(), blockedUsers: Set<String> = emptySet(),

View File

@@ -87,7 +87,6 @@ fun RequestsListScreen(
RequestsScreen( RequestsScreen(
requests = requests, requests = requests,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onBack = onBack,
onRequestClick = { request -> onRequestClick = { request ->
onUserSelect(chatsViewModel.dialogToSearchUser(request)) onUserSelect(chatsViewModel.dialogToSearchUser(request))
}, },

View File

@@ -23,7 +23,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@@ -56,6 +55,7 @@ import com.rosetta.messenger.ui.chats.components.*
import com.rosetta.messenger.ui.chats.utils.* import com.rosetta.messenger.ui.chats.utils.*
import com.rosetta.messenger.ui.chats.ChatViewModel import com.rosetta.messenger.ui.chats.ChatViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Locale import java.util.Locale
@@ -89,7 +89,6 @@ fun MessageInputBar(
isDarkTheme: Boolean, isDarkTheme: Boolean,
backgroundColor: Color, backgroundColor: Color,
textColor: Color, textColor: Color,
placeholderColor: Color,
secondaryTextColor: Color, secondaryTextColor: Color,
replyMessages: List<ChatViewModel.ReplyMessage> = emptyList(), replyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
isForwardMode: Boolean = false, isForwardMode: Boolean = false,
@@ -99,7 +98,6 @@ fun MessageInputBar(
isBlocked: Boolean = false, isBlocked: Boolean = false,
showEmojiPicker: Boolean = false, showEmojiPicker: Boolean = false,
onToggleEmojiPicker: (Boolean) -> Unit = {}, onToggleEmojiPicker: (Boolean) -> Unit = {},
focusRequester: FocusRequester? = null,
coordinator: KeyboardTransitionCoordinator, coordinator: KeyboardTransitionCoordinator,
displayReplyMessages: List<ChatViewModel.ReplyMessage> = emptyList(), displayReplyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
onReplyClick: (String) -> Unit = {}, onReplyClick: (String) -> Unit = {},
@@ -189,7 +187,9 @@ fun MessageInputBar(
// Update coordinator through snapshotFlow (no recomposition) // Update coordinator through snapshotFlow (no recomposition)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }.collect { currentImeHeight -> snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }
.distinctUntilChanged()
.collect { currentImeHeight ->
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val heightChanged = kotlin.math.abs((currentImeHeight - lastStableKeyboardHeight).value) > 5f val heightChanged = kotlin.math.abs((currentImeHeight - lastStableKeyboardHeight).value) > 5f
if (heightChanged && currentImeHeight.value > 0) { if (heightChanged && currentImeHeight.value > 0) {
@@ -202,9 +202,13 @@ fun MessageInputBar(
isKeyboardVisible = currentImeHeight > 50.dp isKeyboardVisible = currentImeHeight > 50.dp
coordinator.updateKeyboardHeight(currentImeHeight) coordinator.updateKeyboardHeight(currentImeHeight)
if (currentImeHeight > 100.dp) { // Update "stable" height only after IME animation settles,
// otherwise low-end devices get many unnecessary recompositions.
if (!isKeyboardAnimating && currentImeHeight > 100.dp) {
coordinator.syncHeights() coordinator.syncHeights()
lastStableKeyboardHeight = currentImeHeight if (kotlin.math.abs((currentImeHeight - lastStableKeyboardHeight).value) > 2f) {
lastStableKeyboardHeight = currentImeHeight
}
} }
} }
} }

View File

@@ -357,6 +357,15 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
try { try {
val textStr = editable.toString() val textStr = editable.toString()
val hasEmojiHints = containsEmojiHints(textStr)
if (!hasEmojiHints) {
// Fast path for plain text: skip heavy grapheme/asset pipeline.
// Also drop stale spans if user removed emoji content.
editable.getSpans(0, editable.length, ImageSpan::class.java).forEach {
editable.removeSpan(it)
}
return
}
val cursorPosition = selectionStart val cursorPosition = selectionStart
// 🔥 Собираем все позиции эмодзи (и Unicode, и :emoji_code:) // 🔥 Собираем все позиции эмодзи (и Unicode, и :emoji_code:)
@@ -430,6 +439,15 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
} }
} }
private fun containsEmojiHints(text: String): Boolean {
if (text.isEmpty()) return false
if (text.indexOf(":emoji_") >= 0) return true
for (ch in text) {
if (Character.isSurrogate(ch) || ch == '\u200D' || ch == '\uFE0F') return true
}
return false
}
private fun loadFromAssets(unified: String): Bitmap? { private fun loadFromAssets(unified: String): Bitmap? {
return try { return try {
val inputStream = getContext().assets.open("emoji/$unified.png") val inputStream = getContext().assets.open("emoji/$unified.png")