diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ae5f511..ad75200 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown" // ═══════════════════════════════════════════════════════════ // Rosetta versioning — bump here on each release // ═══════════════════════════════════════════════════════════ -val rosettaVersionName = "1.2.3" -val rosettaVersionCode = 25 // Increment on each release +val rosettaVersionName = "1.2.4" +val rosettaVersionCode = 26 // Increment on each release android { namespace = "com.rosetta.messenger" diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt index 4d15f0e..411788f 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -19,14 +19,31 @@ object ReleaseNotes { Что обновлено после версии 1.2.3 - Группы и медиа - - Исправлено отображение групповых баблов и стеков сообщений - - Исправлено позиционирование аватарки: имя и аватар в группе теперь не разъезжаются - - Исправлена обрезка имени отправителя в медиа-баблах - - Исправлено растяжение фото в forwarded/media-пузырях + Чат-лист и Requests + - Полностью переработано поведение блока Requests: pull-жест, раскрытие и скрытие как у архива в Telegram + - Доработана вытягивающаяся анимация: requests сразу появляются первым элементом при pull вниз + - Убраны рывки и прыжки списка чатов при анимациях и при пустом списке запросов - Интерфейс - - Убрана лишняя рамка вокруг аватарки в боковом меню + Чаты и группы + - Исправлены групповые баблы и аватарки в стеках сообщений, устранены кривые состояния в медиа-блоках + - Исправлена обрезка имени отправителя в групповых медиа-сообщениях + - Плашки даты в диалоге приведены к Telegram-стилю, добавлена плавающая верхняя дата при скролле + - Сообщение «you joined the group» теперь белого цвета в тёмной теме и на обоях + + Медиа и локальные данные + - Исправлена отправка нескольких фото: добавлен корректный optimistic UI и стабильное отображение до/после перезахода + - Экран редактирования фото после камеры унифицирован с редактором фото из галереи + - Удалённые сообщения теперь корректно удаляются локально и не возвращаются после открытия диалога + + Обои и темы + - Разделены наборы обоев для светлой и тёмной темы + - Исправлено поведение обоев на разных разрешениях: убраны повторения/растяжения, фон отображается стабильнее + + Навигация и UI + - Back-свайп теперь везде скрывает клавиатуру (как на экране поиска) + - На экране группы выровнены размеры иконок Encryption Key и Add Members + - Улучшен back-свайп на экране Encryption Key: возврат во внутреннюю страницу группы + - Приведён к нормальному размер индикатор ошибки в чат-листе """.trimIndent() fun getNotice(version: String): String = diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index d95bae2..19a4125 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -94,7 +94,6 @@ import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert import com.rosetta.messenger.ui.chats.components.* -import com.rosetta.messenger.ui.chats.components.ImageEditorScreen import com.rosetta.messenger.ui.chats.components.InAppCameraScreen import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen import com.rosetta.messenger.ui.chats.input.* @@ -234,6 +233,8 @@ fun ChatDetailScreen( val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) val dateHeaderTextColor = if (isDarkTheme || hasChatWallpaper) Color.White else secondaryTextColor + val dateHeaderBackgroundColor = + if (isDarkTheme || hasChatWallpaper) Color(0x80212121) else Color(0xCCE8E8ED) val headerIconColor = Color.White // 🔥 Keyboard & Emoji Coordinator @@ -391,6 +392,7 @@ fun ChatDetailScreen( var pendingCameraPhotoUri by remember { mutableStateOf(null) } // Фото для редактирования + var pendingCameraPhotoCaption by remember { mutableStateOf("") } // 📷 Показать встроенную камеру (без системного превью) var showInAppCamera by remember { mutableStateOf(false) } @@ -1156,6 +1158,37 @@ fun ChatDetailScreen( val showScrollToBottomButton by remember(messagesWithDates, isAtBottom, isSendingMessage) { derivedStateOf { messagesWithDates.isNotEmpty() && !isAtBottom && !isSendingMessage } } + val floatingDateText by remember(messagesWithDates, listState) { + derivedStateOf { + if (messagesWithDates.isEmpty()) { + return@derivedStateOf null + } + val layoutInfo = listState.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + if (visibleItems.isEmpty()) { + return@derivedStateOf null + } + val topVisibleItem = + visibleItems.minByOrNull { itemInfo -> + kotlin.math.abs(itemInfo.offset - layoutInfo.viewportStartOffset) + } ?: return@derivedStateOf null + val messageIndex = topVisibleItem.index + if (messageIndex !in messagesWithDates.indices) { + return@derivedStateOf null + } + getDateText(messagesWithDates[messageIndex].first.timestamp.time) + } + } + val showFloatingDateHeader by + remember(messagesWithDates, floatingDateText, isAtBottom, listState) { + derivedStateOf { + messagesWithDates.isNotEmpty() && + floatingDateText != null && + !isAtBottom && + (listState.isScrollInProgress || + listState.firstVisibleItemIndex > 0) + } + } // Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации) // 🔥 Скроллим только если изменился ID самого нового сообщения @@ -2555,12 +2588,41 @@ fun ChatDetailScreen( isMessageBoundary(message, prevMessage) val isGroupStart = isMessageBoundary(message, nextMessage) + val runHeadIndex = + messageRunNewestIndex.getOrNull( + index + ) ?: index + val runTailIndex = + messageRunOldestIndexByHead + .getOrNull( + runHeadIndex + ) + ?: runHeadIndex + val isHeadPhase = + incomingRunAvatarUiState + .showOnRunHeads + .contains( + runHeadIndex + ) + val isTailPhase = + incomingRunAvatarUiState + .showOnRunTails + .contains( + runHeadIndex + ) val showIncomingGroupAvatar = isGroupChat && !message.isOutgoing && senderPublicKeyForMessage .isNotBlank() && - isGroupStart + ((index == + runHeadIndex && + isHeadPhase && + showTail) || + (index == + runTailIndex && + isTailPhase && + isGroupStart)) Column { if (showDate @@ -2571,8 +2633,10 @@ fun ChatDetailScreen( message.timestamp .time ), - secondaryTextColor = - dateHeaderTextColor + textColor = + dateHeaderTextColor, + backgroundColor = + dateHeaderBackgroundColor ) } val selectionKey = @@ -2943,6 +3007,42 @@ fun ChatDetailScreen( } } } + androidx.compose.animation.AnimatedVisibility( + visible = + showFloatingDateHeader && + !isLoading && + !isSelectionMode, + enter = + fadeIn(animationSpec = tween(120)) + + slideInVertically( + animationSpec = + tween(120) + ) { height -> + -height / 2 + }, + exit = + fadeOut(animationSpec = tween(100)) + + slideOutVertically( + animationSpec = + tween(100) + ) { height -> + -height / 2 + }, + modifier = + Modifier.align(Alignment.TopCenter) + .padding(top = 8.dp) + .zIndex(3f) + ) { + floatingDateText?.let { dateText -> + DateHeader( + dateText = dateText, + textColor = dateHeaderTextColor, + backgroundColor = + dateHeaderBackgroundColor, + verticalPadding = 0.dp + ) + } + } if (incomingRunAvatarUiState.overlays.isNotEmpty()) { val avatarInsetPx = with(density) { @@ -3560,7 +3660,8 @@ fun ChatDetailScreen( InAppCameraScreen( onDismiss = { showInAppCamera = false }, onPhotoTaken = { photoUri -> - // Сначала редактор (skipEnterAnimation=1f), потом убираем камеру + // После камеры открываем тот же fullscreen-редактор, + // что и для фото из галереи. pendingCameraPhotoUri = photoUri showInAppCamera = false } @@ -3569,26 +3670,25 @@ fun ChatDetailScreen( // 📷 Image Editor для фото с камеры (с caption как в Telegram) pendingCameraPhotoUri?.let { uri -> - ImageEditorScreen( + SimpleFullscreenPhotoOverlay( imageUri = uri, - onDismiss = { - pendingCameraPhotoUri = null - inputFocusTrigger++ - }, - onSave = { editedUri -> - // 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ - viewModel.sendImageFromUri(editedUri, "") - showMediaPicker = false - }, - onSaveWithCaption = { editedUri, caption -> - // 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ + modifier = Modifier.fillMaxSize().zIndex(100f), + showCaptionInput = true, + caption = pendingCameraPhotoCaption, + onCaptionChange = { pendingCameraPhotoCaption = it }, + isDarkTheme = isDarkTheme, + onSend = { editedUri, caption -> viewModel.sendImageFromUri(editedUri, caption) showMediaPicker = false + pendingCameraPhotoUri = null + pendingCameraPhotoCaption = "" + inputFocusTrigger++ }, - isDarkTheme = isDarkTheme, - showCaptionInput = true, - recipientName = user.title, - skipEnterAnimation = true // Из камеры — мгновенно, без fade + onDismiss = { + pendingCameraPhotoUri = null + pendingCameraPhotoCaption = "" + inputFocusTrigger++ + } ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 30e2c85..3ca1e9d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -2169,15 +2169,27 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { /** 🔥 Удалить сообщение (для ошибки отправки) */ fun deleteMessage(messageId: String) { + val account = myPublicKey ?: return + val opponent = opponentKey ?: return + val dialogKey = getDialogKey(account, opponent) + // Удаляем из UI сразу на main - _messages.value = _messages.value.filter { it.id != messageId } + val updatedMessages = _messages.value.filter { it.id != messageId } + _messages.value = updatedMessages + // Синхронизируем глобальный кэш диалога, иначе удалённые сообщения могут вернуться + // при повторном открытии чата из stale cache. + updateCacheWithLimit(account, dialogKey, updatedMessages) + messageRepository.clearDialogCache(opponent) // Удаляем из БД в IO + удаляем pin если был viewModelScope.launch(Dispatchers.IO) { - val account = myPublicKey ?: return@launch - val dialogKey = opponentKey ?: return@launch pinnedMessageDao.removePin(account, dialogKey, messageId) messageDao.deleteMessage(account, messageId) + if (account == opponent) { + dialogDao.updateSavedMessagesDialogFromMessages(account) + } else { + dialogDao.updateDialogFromMessages(account, opponent) + } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index ccb394c..11f9312 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -4462,17 +4462,17 @@ fun DialogItemContent( Spacer(modifier = Modifier.width(8.dp)) Box( modifier = - Modifier.size(22.dp) + Modifier.size(16.dp) .clip(CircleShape) .background(Color(0xFFE53935)), contentAlignment = Alignment.Center ) { Text( text = "!", - fontSize = 13.sp, + fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.White, - lineHeight = 13.sp, + lineHeight = 10.sp, maxLines = 1 ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt index ffa455a..fa37fc7 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -137,6 +137,7 @@ import com.rosetta.messenger.ui.chats.components.ViewableImage import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.SharedMediaFastScrollOverlay +import com.rosetta.messenger.ui.components.SwipeBackContainer import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.icons.TelegramIcons import com.rosetta.messenger.ui.settings.FullScreenAvatarViewer @@ -339,6 +340,7 @@ fun GroupInfoScreen( val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6) val actionContentColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E) val groupActionButtonBlue = if (isDarkTheme) Color(0xFF4A4A4D) else Color(0xFF2478C2) + val groupMenuTrailingIconSize = 22.dp LaunchedEffect(Unit) { val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager @@ -805,8 +807,8 @@ fun GroupInfoScreen( swipedMemberKey = null } } - LaunchedEffect(swipedMemberKey) { - onSwipeBackEnabledChanged(swipedMemberKey == null) + LaunchedEffect(swipedMemberKey, showEncryptionPage) { + onSwipeBackEnabledChanged(swipedMemberKey == null && !showEncryptionPage) } DisposableEffect(Unit) { onDispose { @@ -1207,7 +1209,7 @@ fun GroupInfoScreen( imageVector = Icons.Default.PersonAdd, contentDescription = null, tint = accentColor, - modifier = Modifier.size(22.dp) + modifier = Modifier.size(groupMenuTrailingIconSize) ) } @@ -1233,7 +1235,7 @@ fun GroupInfoScreen( ) if (encryptionKeyLoading) { CircularProgressIndicator( - modifier = Modifier.size(20.dp), + modifier = Modifier.size(groupMenuTrailingIconSize), strokeWidth = 2.dp, color = accentColor ) @@ -1241,12 +1243,12 @@ fun GroupInfoScreen( val identiconKey = encryptionKey.ifBlank { dialogPublicKey } Box( modifier = Modifier - .size(34.dp) - .clip(RoundedCornerShape(6.dp)) + .size(groupMenuTrailingIconSize) + .clip(RoundedCornerShape(3.dp)) ) { TelegramStyleIdenticon( keyRender = identiconKey, - size = 34.dp, + size = groupMenuTrailingIconSize, isDarkTheme = isDarkTheme ) } @@ -1565,11 +1567,12 @@ fun GroupInfoScreen( ) } - AnimatedVisibility( - visible = showEncryptionPage, - enter = fadeIn(animationSpec = tween(durationMillis = 260)), - exit = fadeOut(animationSpec = tween(durationMillis = 200)), - modifier = Modifier.fillMaxSize() + SwipeBackContainer( + isVisible = showEncryptionPage, + onBack = { showEncryptionPage = false }, + isDarkTheme = isDarkTheme, + layer = 3, + propagateBackgroundProgress = false ) { val displayLines = remember(encryptionKey) { encodeGroupKeyForDisplay(encryptionKey) } GroupEncryptionKeyPage( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 27f33f3..1a11220 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -59,6 +59,7 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.PopupProperties @@ -191,37 +192,33 @@ fun TelegramStyleMessageContent( private data class LayoutResult(val width: Int, val height: Int, val timeX: Int, val timeY: Int) -/** Date header with fade-in animation */ +/** Telegram-like date header chip (inline separator and floating top badge). */ @Composable -fun DateHeader(dateText: String, secondaryTextColor: Color) { - var isVisible by remember { mutableStateOf(false) } - val alpha by - animateFloatAsState( - targetValue = if (isVisible) 1f else 0f, - animationSpec = tween(durationMillis = 250, easing = TelegramEasing), - label = "dateAlpha" - ) - - LaunchedEffect(dateText) { isVisible = true } - +fun DateHeader( + dateText: String, + textColor: Color, + backgroundColor: Color, + modifier: Modifier = Modifier, + verticalPadding: Dp = 12.dp +) { Row( modifier = - Modifier.fillMaxWidth().padding(vertical = 12.dp).graphicsLayer { - this.alpha = alpha - }, + modifier + .fillMaxWidth() + .padding(vertical = verticalPadding), horizontalArrangement = Arrangement.Center ) { Text( text = dateText, - fontSize = 13.sp, + fontSize = 12.sp, fontWeight = FontWeight.Medium, - color = secondaryTextColor, + color = textColor, modifier = Modifier.background( - color = secondaryTextColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(12.dp) + color = backgroundColor, + shape = RoundedCornerShape(10.dp) ) - .padding(horizontal = 12.dp, vertical = 4.dp) + .padding(horizontal = 10.dp, vertical = 3.dp) ) } }