Релиз 1.4.5: стабилизация звонков, фиксы UI
All checks were successful
Android Kernel Build / build (push) Successful in 19m24s
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:
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Rosetta versioning — bump here on each release
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val rosettaVersionName = "1.4.4"
|
||||
val rosettaVersionCode = 46 // Increment on each release
|
||||
val rosettaVersionName = "1.4.5"
|
||||
val rosettaVersionCode = 47 // Increment on each release
|
||||
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
||||
|
||||
android {
|
||||
|
||||
@@ -1122,18 +1122,13 @@ fun MainScreen(
|
||||
accountName = accountName,
|
||||
accountUsername = accountUsername,
|
||||
accountVerified = accountVerified,
|
||||
accountPhone = accountPhone,
|
||||
accountPublicKey = accountPublicKey,
|
||||
accountPrivateKey = accountPrivateKey,
|
||||
privateKeyHash = privateKeyHash,
|
||||
onToggleTheme = onToggleTheme,
|
||||
onProfileClick = { pushScreen(Screen.Profile) },
|
||||
onNewGroupClick = {
|
||||
pushScreen(Screen.GroupSetup)
|
||||
},
|
||||
onContactsClick = {
|
||||
// TODO: Navigate to contacts
|
||||
},
|
||||
onCallsClick = {
|
||||
// TODO: Navigate to calls
|
||||
},
|
||||
@@ -1152,9 +1147,6 @@ fun MainScreen(
|
||||
)
|
||||
},
|
||||
onSettingsClick = { pushScreen(Screen.Profile) },
|
||||
onInviteFriendsClick = {
|
||||
// TODO: Share invite link
|
||||
},
|
||||
onSearchClick = { pushScreen(Screen.Search) },
|
||||
onRequestsClick = { pushScreen(Screen.Requests) },
|
||||
onNewChat = {
|
||||
@@ -1166,7 +1158,6 @@ fun MainScreen(
|
||||
onStartCall = { user ->
|
||||
startCallWithPermission(user)
|
||||
},
|
||||
backgroundBlurColorId = backgroundBlurColorId,
|
||||
pinnedChats = pinnedChats,
|
||||
onTogglePin = { opponentKey ->
|
||||
mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) }
|
||||
|
||||
@@ -18,15 +18,31 @@ object ReleaseNotes {
|
||||
Update v$VERSION_PLACEHOLDER
|
||||
|
||||
Звонки
|
||||
- Обновлён протокол WebRTC: publicKey и deviceId в каждом пакете
|
||||
- Полноэкранный входящий звонок на экране блокировки
|
||||
- Фикс бесконечного "Exchanging keys" при принятии звонка
|
||||
- Фикс краша ForegroundService при исходящем звонке
|
||||
- Кастомный WebRTC с E2EE теперь работает в CI-сборках
|
||||
- Полноэкранный входящий звонок (IncomingCallActivity) поверх экрана блокировки с кнопками Принять/Отклонить
|
||||
- Обновлён протокол WebRTC: publicKey и deviceId в каждом пакете (совместимость с новым сервером)
|
||||
- Звонок больше не сбрасывается при переподключении WebSocket
|
||||
- Исправлен бесконечный статус "Exchanging keys" — KEY_EXCHANGE отправляется с ретраем до 6 сек
|
||||
- Автоматическая привязка аккаунта при принятии звонка из 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()
|
||||
|
||||
fun getNotice(version: String): String =
|
||||
|
||||
@@ -368,9 +368,6 @@ fun ChatDetailScreen(
|
||||
// 🎨 Window reference для управления статус баром
|
||||
val window = remember { (view.context as? Activity)?.window }
|
||||
|
||||
// 🔥 Focus state for input
|
||||
val inputFocusRequester = remember { FocusRequester() }
|
||||
|
||||
// 🔥 Emoji picker state
|
||||
var showEmojiPicker by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -539,7 +536,7 @@ fun ChatDetailScreen(
|
||||
} else {
|
||||
val isOverlayControllingSystemBars = showMediaPicker
|
||||
|
||||
if (!isOverlayControllingSystemBars && window != null && view != null) {
|
||||
if (!isOverlayControllingSystemBars && window != null) {
|
||||
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||
ic.isAppearanceLightStatusBars = false
|
||||
@@ -557,7 +554,7 @@ fun ChatDetailScreen(
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
// Восстановить белые иконки статус-бара для chat list header
|
||||
if (window != null && view != null) {
|
||||
if (window != null) {
|
||||
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||
ic.isAppearanceLightStatusBars = false
|
||||
@@ -571,9 +568,6 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 📷 Camera: URI для сохранения фото
|
||||
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
|
||||
|
||||
// 📷 Состояние для flow камеры: фото → редактор с caption → отправка
|
||||
var pendingCameraPhotoUri by remember {
|
||||
mutableStateOf<Uri?>(null)
|
||||
@@ -648,47 +642,6 @@ fun ChatDetailScreen(
|
||||
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
|
||||
val filePickerLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
@@ -827,7 +780,6 @@ fun ChatDetailScreen(
|
||||
|
||||
// Подключаем к ViewModel
|
||||
val messages by viewModel.messages.collectAsState()
|
||||
val inputText by viewModel.inputText.collectAsState()
|
||||
val isTyping by viewModel.opponentTyping.collectAsState()
|
||||
val typingDisplayName by viewModel.typingDisplayName.collectAsState()
|
||||
val typingDisplayPublicKey by viewModel.typingDisplayPublicKey.collectAsState()
|
||||
@@ -2704,66 +2656,40 @@ fun ChatDetailScreen(
|
||||
} else if (!isSystemAccount) {
|
||||
// INPUT BAR
|
||||
Column {
|
||||
MessageInputBar(
|
||||
value = inputText,
|
||||
onValueChange = {
|
||||
viewModel
|
||||
.updateInputText(
|
||||
it
|
||||
)
|
||||
if (it.isNotEmpty() &&
|
||||
!isSavedMessages
|
||||
) {
|
||||
viewModel
|
||||
.sendTypingIndicator()
|
||||
}
|
||||
},
|
||||
ChatInputBarSection(
|
||||
viewModel = viewModel,
|
||||
isSavedMessages = isSavedMessages,
|
||||
onSend = {
|
||||
isSendingMessage =
|
||||
true
|
||||
viewModel
|
||||
.sendMessage()
|
||||
isSendingMessage = true
|
||||
viewModel.sendMessage()
|
||||
scope.launch {
|
||||
delay(100)
|
||||
listState
|
||||
.animateScrollToItem(
|
||||
0
|
||||
)
|
||||
listState.animateScrollToItem(0)
|
||||
delay(300)
|
||||
isSendingMessage =
|
||||
false
|
||||
isSendingMessage = false
|
||||
}
|
||||
},
|
||||
isDarkTheme = isDarkTheme,
|
||||
backgroundColor =
|
||||
backgroundColor,
|
||||
backgroundColor = backgroundColor,
|
||||
textColor = textColor,
|
||||
placeholderColor =
|
||||
secondaryTextColor,
|
||||
secondaryTextColor =
|
||||
secondaryTextColor,
|
||||
replyMessages =
|
||||
replyMessages,
|
||||
isForwardMode =
|
||||
isForwardMode,
|
||||
replyMessages = replyMessages,
|
||||
isForwardMode = isForwardMode,
|
||||
onCloseReply = {
|
||||
viewModel
|
||||
.clearReplyMessages()
|
||||
viewModel.clearReplyMessages()
|
||||
},
|
||||
onShowForwardOptions = { panelMessages ->
|
||||
if (panelMessages.isEmpty()) {
|
||||
return@MessageInputBar
|
||||
return@ChatInputBarSection
|
||||
}
|
||||
val forwardMessages =
|
||||
panelMessages.map { msg ->
|
||||
ForwardManager.ForwardMessage(
|
||||
messageId =
|
||||
msg.messageId,
|
||||
messageId = msg.messageId,
|
||||
text = msg.text,
|
||||
timestamp =
|
||||
msg.timestamp,
|
||||
isOutgoing =
|
||||
msg.isOutgoing,
|
||||
timestamp = msg.timestamp,
|
||||
isOutgoing = msg.isOutgoing,
|
||||
senderPublicKey =
|
||||
msg.publicKey.ifEmpty {
|
||||
if (msg.isOutgoing) currentUserPublicKey
|
||||
@@ -2793,43 +2719,28 @@ fun ChatDetailScreen(
|
||||
},
|
||||
chatTitle = chatTitle,
|
||||
isBlocked = isBlocked,
|
||||
showEmojiPicker =
|
||||
showEmojiPicker,
|
||||
showEmojiPicker = showEmojiPicker,
|
||||
onToggleEmojiPicker = {
|
||||
showEmojiPicker = it
|
||||
},
|
||||
focusRequester =
|
||||
inputFocusRequester,
|
||||
coordinator = coordinator,
|
||||
displayReplyMessages =
|
||||
displayReplyMessages,
|
||||
onReplyClick =
|
||||
scrollToMessage,
|
||||
onReplyClick = scrollToMessage,
|
||||
onAttachClick = {
|
||||
// Telegram-style:
|
||||
// галерея
|
||||
// открывается
|
||||
// ПОВЕРХ клавиатуры
|
||||
// НЕ скрываем
|
||||
// клавиатуру!
|
||||
showMediaPicker =
|
||||
true
|
||||
// галерея открывается поверх клавиатуры.
|
||||
showMediaPicker = true
|
||||
},
|
||||
myPublicKey =
|
||||
viewModel
|
||||
.myPublicKey
|
||||
?: "",
|
||||
opponentPublicKey =
|
||||
user.publicKey,
|
||||
myPrivateKey =
|
||||
currentUserPrivateKey,
|
||||
viewModel.myPublicKey ?: "",
|
||||
opponentPublicKey = user.publicKey,
|
||||
myPrivateKey = currentUserPrivateKey,
|
||||
isGroupChat = isGroupChat,
|
||||
mentionCandidates = mentionCandidates,
|
||||
avatarRepository = avatarRepository,
|
||||
inputFocusTrigger =
|
||||
inputFocusTrigger,
|
||||
suppressKeyboard =
|
||||
showInAppCamera,
|
||||
inputFocusTrigger = inputFocusTrigger,
|
||||
suppressKeyboard = showInAppCamera,
|
||||
hasNativeNavigationBar =
|
||||
hasNativeNavigationBar
|
||||
)
|
||||
@@ -4311,6 +4222,76 @@ fun ChatDetailScreen(
|
||||
} // Закрытие 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
|
||||
private fun GroupMembersSubtitleSkeleton() {
|
||||
val transition = rememberInfiniteTransition(label = "groupMembersSkeleton")
|
||||
|
||||
@@ -264,24 +264,19 @@ fun ChatsListScreen(
|
||||
accountName: String,
|
||||
accountUsername: String,
|
||||
accountVerified: Int = 0,
|
||||
accountPhone: String,
|
||||
accountPublicKey: String,
|
||||
accountPrivateKey: String = "",
|
||||
privateKeyHash: String = "",
|
||||
onToggleTheme: () -> Unit,
|
||||
onProfileClick: () -> Unit,
|
||||
onNewGroupClick: () -> Unit,
|
||||
onContactsClick: () -> Unit,
|
||||
onCallsClick: () -> Unit,
|
||||
onSavedMessagesClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
onInviteFriendsClick: () -> Unit,
|
||||
onSearchClick: () -> Unit,
|
||||
onRequestsClick: () -> Unit = {},
|
||||
onNewChat: () -> Unit,
|
||||
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
|
||||
onStartCall: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
|
||||
backgroundBlurColorId: String = "avatar",
|
||||
pinnedChats: Set<String> = emptySet(),
|
||||
onTogglePin: (String) -> Unit = {},
|
||||
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
||||
@@ -462,7 +457,6 @@ fun ChatsListScreen(
|
||||
|
||||
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
|
||||
// сообщений
|
||||
val initStart = System.currentTimeMillis()
|
||||
ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey)
|
||||
android.util.Log.d(
|
||||
"ChatsListScreen",
|
||||
@@ -569,10 +563,6 @@ fun ChatsListScreen(
|
||||
allAccounts = accounts.sortedByDescending { it.publicKey == accountPublicKey }
|
||||
}
|
||||
|
||||
// 🔥 Используем rememberSaveable чтобы сохранить состояние при навигации
|
||||
// Header сразу visible = true, без анимации при возврате из чата
|
||||
var visible by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
// Confirmation dialogs state
|
||||
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
|
||||
var dialogToLeave by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||
@@ -794,13 +784,6 @@ fun ChatsListScreen(
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🎨 DRAWER HEADER
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val avatarColors =
|
||||
getAvatarColor(
|
||||
accountPublicKey,
|
||||
isDarkTheme
|
||||
)
|
||||
val headerColor = avatarColors.backgroundColor
|
||||
|
||||
// Header: цвет шапки сайдбара
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
@@ -3462,9 +3445,6 @@ fun ChatItem(
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
|
||||
|
||||
val avatarColors = getAvatarColor(chat.publicKey, isDarkTheme)
|
||||
val avatarText = getAvatarText(chat.publicKey)
|
||||
|
||||
Column {
|
||||
Row(
|
||||
modifier =
|
||||
@@ -3939,13 +3919,11 @@ fun SwipeableDialogItem(
|
||||
velocityTracker.resetTracking()
|
||||
var totalDragX = 0f
|
||||
var totalDragY = 0f
|
||||
var passedSlop = false
|
||||
var claimed = false
|
||||
|
||||
// Phase 1: Determine gesture type (tap / long-press / drag)
|
||||
// Wait up to longPressTimeout; if no up or slop → long press
|
||||
var gestureType = "unknown"
|
||||
var fingerIsUp = false
|
||||
|
||||
val result = withTimeoutOrNull(longPressTimeoutMs) {
|
||||
while (true) {
|
||||
@@ -3982,19 +3960,17 @@ fun SwipeableDialogItem(
|
||||
// Timeout → check if finger lifted during the race window
|
||||
if (result == null) {
|
||||
// Grace period: check if up event arrived just as timeout fired
|
||||
val graceResult = withTimeoutOrNull(32L) {
|
||||
withTimeoutOrNull(32L) {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null) {
|
||||
gestureType = "cancelled"
|
||||
fingerIsUp = true
|
||||
return@withTimeoutOrNull Unit
|
||||
}
|
||||
if (change.changedToUpIgnoreConsumed()) {
|
||||
change.consume()
|
||||
gestureType = "tap"
|
||||
fingerIsUp = true
|
||||
return@withTimeoutOrNull Unit
|
||||
}
|
||||
// Still moving/holding — it's a real long press
|
||||
@@ -4034,13 +4010,11 @@ fun SwipeableDialogItem(
|
||||
when {
|
||||
// Horizontal left swipe — reveal action buttons
|
||||
currentSwipeEnabled && dominated && totalDragX < 0 -> {
|
||||
passedSlop = true
|
||||
claimed = true
|
||||
currentOnSwipeStarted()
|
||||
}
|
||||
// Horizontal right swipe with buttons open — close them
|
||||
dominated && totalDragX > 0 && offsetX != 0f -> {
|
||||
passedSlop = true
|
||||
claimed = true
|
||||
}
|
||||
// Right swipe with buttons closed — let drawer handle
|
||||
@@ -4144,10 +4118,6 @@ fun DialogItemContent(
|
||||
val secondaryTextColor =
|
||||
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) }
|
||||
|
||||
// 📁 Для 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(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
@@ -5003,7 +4941,6 @@ private fun RequestsRouteContent(
|
||||
RequestsScreen(
|
||||
requests = requests,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onBack = onBack,
|
||||
onRequestClick = onRequestClick,
|
||||
avatarRepository = avatarRepository,
|
||||
blockedUsers = blockedUsers,
|
||||
@@ -5138,7 +5075,6 @@ fun RequestsSection(
|
||||
fun RequestsScreen(
|
||||
requests: List<DialogUiModel>,
|
||||
isDarkTheme: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onRequestClick: (DialogUiModel) -> Unit,
|
||||
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
||||
blockedUsers: Set<String> = emptySet(),
|
||||
|
||||
@@ -87,7 +87,6 @@ fun RequestsListScreen(
|
||||
RequestsScreen(
|
||||
requests = requests,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onBack = onBack,
|
||||
onRequestClick = { request ->
|
||||
onUserSelect(chatsViewModel.dialogToSearchUser(request))
|
||||
},
|
||||
|
||||
@@ -23,7 +23,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
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.ChatViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
|
||||
@@ -89,7 +89,6 @@ fun MessageInputBar(
|
||||
isDarkTheme: Boolean,
|
||||
backgroundColor: Color,
|
||||
textColor: Color,
|
||||
placeholderColor: Color,
|
||||
secondaryTextColor: Color,
|
||||
replyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
|
||||
isForwardMode: Boolean = false,
|
||||
@@ -99,7 +98,6 @@ fun MessageInputBar(
|
||||
isBlocked: Boolean = false,
|
||||
showEmojiPicker: Boolean = false,
|
||||
onToggleEmojiPicker: (Boolean) -> Unit = {},
|
||||
focusRequester: FocusRequester? = null,
|
||||
coordinator: KeyboardTransitionCoordinator,
|
||||
displayReplyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
|
||||
onReplyClick: (String) -> Unit = {},
|
||||
@@ -189,7 +187,9 @@ fun MessageInputBar(
|
||||
|
||||
// Update coordinator through snapshotFlow (no recomposition)
|
||||
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 heightChanged = kotlin.math.abs((currentImeHeight - lastStableKeyboardHeight).value) > 5f
|
||||
if (heightChanged && currentImeHeight.value > 0) {
|
||||
@@ -202,9 +202,13 @@ fun MessageInputBar(
|
||||
|
||||
isKeyboardVisible = currentImeHeight > 50.dp
|
||||
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()
|
||||
lastStableKeyboardHeight = currentImeHeight
|
||||
if (kotlin.math.abs((currentImeHeight - lastStableKeyboardHeight).value) > 2f) {
|
||||
lastStableKeyboardHeight = currentImeHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +357,15 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
|
||||
|
||||
try {
|
||||
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
|
||||
|
||||
// 🔥 Собираем все позиции эмодзи (и 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? {
|
||||
return try {
|
||||
val inputStream = getContext().assets.open("emoji/$unified.png")
|
||||
|
||||
Reference in New Issue
Block a user