Релиз 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
|
// 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 {
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user