Релиз 1.2.4: фиксы чатов, медиа и release notes

This commit is contained in:
2026-03-20 00:44:18 +05:00
parent 5ecb2a8db4
commit 004b54ec7c
7 changed files with 197 additions and 68 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.2.3" val rosettaVersionName = "1.2.4"
val rosettaVersionCode = 25 // Increment on each release val rosettaVersionCode = 26 // Increment on each release
android { android {
namespace = "com.rosetta.messenger" namespace = "com.rosetta.messenger"

View File

@@ -19,14 +19,31 @@ object ReleaseNotes {
Что обновлено после версии 1.2.3 Что обновлено после версии 1.2.3
Группы и медиа Чат-лист и Requests
- Исправлено отображение групповых баблов и стеков сообщений - Полностью переработано поведение блока Requests: pull-жест, раскрытие и скрытие как у архива в Telegram
- Исправлено позиционирование аватарки: имя и аватар в группе теперь не разъезжаются - Доработана вытягивающаяся анимация: requests сразу появляются первым элементом при pull вниз
- Исправлена обрезка имени отправителя в медиа-баблах - Убраны рывки и прыжки списка чатов при анимациях и при пустом списке запросов
- Исправлено растяжение фото в forwarded/media-пузырях
Интерфейс Чаты и группы
- Убрана лишняя рамка вокруг аватарки в боковом меню - Исправлены групповые баблы и аватарки в стеках сообщений, устранены кривые состояния в медиа-блоках
- Исправлена обрезка имени отправителя в групповых медиа-сообщениях
- Плашки даты в диалоге приведены к Telegram-стилю, добавлена плавающая верхняя дата при скролле
- Сообщение «you joined the group» теперь белого цвета в тёмной теме и на обоях
Медиа и локальные данные
- Исправлена отправка нескольких фото: добавлен корректный optimistic UI и стабильное отображение до/после перезахода
- Экран редактирования фото после камеры унифицирован с редактором фото из галереи
- Удалённые сообщения теперь корректно удаляются локально и не возвращаются после открытия диалога
Обои и темы
- Разделены наборы обоев для светлой и тёмной темы
- Исправлено поведение обоев на разных разрешениях: убраны повторения/растяжения, фон отображается стабильнее
Навигация и UI
- Back-свайп теперь везде скрывает клавиатуру (как на экране поиска)
- На экране группы выровнены размеры иконок Encryption Key и Add Members
- Улучшен back-свайп на экране Encryption Key: возврат во внутреннюю страницу группы
- Приведён к нормальному размер индикатор ошибки в чат-листе
""".trimIndent() """.trimIndent()
fun getNotice(version: String): String = fun getNotice(version: String): String =

View File

@@ -94,7 +94,6 @@ import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert
import com.rosetta.messenger.ui.chats.components.* 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.InAppCameraScreen
import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen
import com.rosetta.messenger.ui.chats.input.* import com.rosetta.messenger.ui.chats.input.*
@@ -234,6 +233,8 @@ fun ChatDetailScreen(
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
val dateHeaderTextColor = if (isDarkTheme || hasChatWallpaper) Color.White else secondaryTextColor val dateHeaderTextColor = if (isDarkTheme || hasChatWallpaper) Color.White else secondaryTextColor
val dateHeaderBackgroundColor =
if (isDarkTheme || hasChatWallpaper) Color(0x80212121) else Color(0xCCE8E8ED)
val headerIconColor = Color.White val headerIconColor = Color.White
// 🔥 Keyboard & Emoji Coordinator // 🔥 Keyboard & Emoji Coordinator
@@ -391,6 +392,7 @@ fun ChatDetailScreen(
var pendingCameraPhotoUri by remember { var pendingCameraPhotoUri by remember {
mutableStateOf<Uri?>(null) mutableStateOf<Uri?>(null)
} // Фото для редактирования } // Фото для редактирования
var pendingCameraPhotoCaption by remember { mutableStateOf("") }
// 📷 Показать встроенную камеру (без системного превью) // 📷 Показать встроенную камеру (без системного превью)
var showInAppCamera by remember { mutableStateOf(false) } var showInAppCamera by remember { mutableStateOf(false) }
@@ -1156,6 +1158,37 @@ fun ChatDetailScreen(
val showScrollToBottomButton by remember(messagesWithDates, isAtBottom, isSendingMessage) { val showScrollToBottomButton by remember(messagesWithDates, isAtBottom, isSendingMessage) {
derivedStateOf { messagesWithDates.isNotEmpty() && !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: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации) // Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации)
// 🔥 Скроллим только если изменился ID самого нового сообщения // 🔥 Скроллим только если изменился ID самого нового сообщения
@@ -2555,12 +2588,41 @@ fun ChatDetailScreen(
isMessageBoundary(message, prevMessage) isMessageBoundary(message, prevMessage)
val isGroupStart = val isGroupStart =
isMessageBoundary(message, nextMessage) 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 = val showIncomingGroupAvatar =
isGroupChat && isGroupChat &&
!message.isOutgoing && !message.isOutgoing &&
senderPublicKeyForMessage senderPublicKeyForMessage
.isNotBlank() && .isNotBlank() &&
isGroupStart ((index ==
runHeadIndex &&
isHeadPhase &&
showTail) ||
(index ==
runTailIndex &&
isTailPhase &&
isGroupStart))
Column { Column {
if (showDate if (showDate
@@ -2571,8 +2633,10 @@ fun ChatDetailScreen(
message.timestamp message.timestamp
.time .time
), ),
secondaryTextColor = textColor =
dateHeaderTextColor dateHeaderTextColor,
backgroundColor =
dateHeaderBackgroundColor
) )
} }
val selectionKey = 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()) { if (incomingRunAvatarUiState.overlays.isNotEmpty()) {
val avatarInsetPx = val avatarInsetPx =
with(density) { with(density) {
@@ -3560,7 +3660,8 @@ fun ChatDetailScreen(
InAppCameraScreen( InAppCameraScreen(
onDismiss = { showInAppCamera = false }, onDismiss = { showInAppCamera = false },
onPhotoTaken = { photoUri -> onPhotoTaken = { photoUri ->
// Сначала редактор (skipEnterAnimation=1f), потом убираем камеру // После камеры открываем тот же fullscreen-редактор,
// что и для фото из галереи.
pendingCameraPhotoUri = photoUri pendingCameraPhotoUri = photoUri
showInAppCamera = false showInAppCamera = false
} }
@@ -3569,26 +3670,25 @@ fun ChatDetailScreen(
// 📷 Image Editor для фото с камеры (с caption как в Telegram) // 📷 Image Editor для фото с камеры (с caption как в Telegram)
pendingCameraPhotoUri?.let { uri -> pendingCameraPhotoUri?.let { uri ->
ImageEditorScreen( SimpleFullscreenPhotoOverlay(
imageUri = uri, imageUri = uri,
onDismiss = { modifier = Modifier.fillMaxSize().zIndex(100f),
pendingCameraPhotoUri = null showCaptionInput = true,
inputFocusTrigger++ caption = pendingCameraPhotoCaption,
}, onCaptionChange = { pendingCameraPhotoCaption = it },
onSave = { editedUri -> isDarkTheme = isDarkTheme,
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ onSend = { editedUri, caption ->
viewModel.sendImageFromUri(editedUri, "")
showMediaPicker = false
},
onSaveWithCaption = { editedUri, caption ->
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
viewModel.sendImageFromUri(editedUri, caption) viewModel.sendImageFromUri(editedUri, caption)
showMediaPicker = false showMediaPicker = false
pendingCameraPhotoUri = null
pendingCameraPhotoCaption = ""
inputFocusTrigger++
}, },
isDarkTheme = isDarkTheme, onDismiss = {
showCaptionInput = true, pendingCameraPhotoUri = null
recipientName = user.title, pendingCameraPhotoCaption = ""
skipEnterAnimation = true // Из камеры — мгновенно, без fade inputFocusTrigger++
}
) )
} }

View File

@@ -2169,15 +2169,27 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** 🔥 Удалить сообщение (для ошибки отправки) */ /** 🔥 Удалить сообщение (для ошибки отправки) */
fun deleteMessage(messageId: String) { fun deleteMessage(messageId: String) {
val account = myPublicKey ?: return
val opponent = opponentKey ?: return
val dialogKey = getDialogKey(account, opponent)
// Удаляем из UI сразу на main // Удаляем из 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 если был // Удаляем из БД в IO + удаляем pin если был
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val account = myPublicKey ?: return@launch
val dialogKey = opponentKey ?: return@launch
pinnedMessageDao.removePin(account, dialogKey, messageId) pinnedMessageDao.removePin(account, dialogKey, messageId)
messageDao.deleteMessage(account, messageId) messageDao.deleteMessage(account, messageId)
if (account == opponent) {
dialogDao.updateSavedMessagesDialogFromMessages(account)
} else {
dialogDao.updateDialogFromMessages(account, opponent)
}
} }
} }

View File

@@ -4462,17 +4462,17 @@ fun DialogItemContent(
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Box( Box(
modifier = modifier =
Modifier.size(22.dp) Modifier.size(16.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color(0xFFE53935)), .background(Color(0xFFE53935)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = "!", text = "!",
fontSize = 13.sp, fontSize = 10.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.White, color = Color.White,
lineHeight = 13.sp, lineHeight = 10.sp,
maxLines = 1 maxLines = 1
) )
} }

View File

@@ -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.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.SharedMediaFastScrollOverlay 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.components.VerifiedBadge
import com.rosetta.messenger.ui.icons.TelegramIcons import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.settings.FullScreenAvatarViewer import com.rosetta.messenger.ui.settings.FullScreenAvatarViewer
@@ -339,6 +340,7 @@ fun GroupInfoScreen(
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6) val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
val actionContentColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E) val actionContentColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E)
val groupActionButtonBlue = if (isDarkTheme) Color(0xFF4A4A4D) else Color(0xFF2478C2) val groupActionButtonBlue = if (isDarkTheme) Color(0xFF4A4A4D) else Color(0xFF2478C2)
val groupMenuTrailingIconSize = 22.dp
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
@@ -805,8 +807,8 @@ fun GroupInfoScreen(
swipedMemberKey = null swipedMemberKey = null
} }
} }
LaunchedEffect(swipedMemberKey) { LaunchedEffect(swipedMemberKey, showEncryptionPage) {
onSwipeBackEnabledChanged(swipedMemberKey == null) onSwipeBackEnabledChanged(swipedMemberKey == null && !showEncryptionPage)
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
@@ -1207,7 +1209,7 @@ fun GroupInfoScreen(
imageVector = Icons.Default.PersonAdd, imageVector = Icons.Default.PersonAdd,
contentDescription = null, contentDescription = null,
tint = accentColor, tint = accentColor,
modifier = Modifier.size(22.dp) modifier = Modifier.size(groupMenuTrailingIconSize)
) )
} }
@@ -1233,7 +1235,7 @@ fun GroupInfoScreen(
) )
if (encryptionKeyLoading) { if (encryptionKeyLoading) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(20.dp), modifier = Modifier.size(groupMenuTrailingIconSize),
strokeWidth = 2.dp, strokeWidth = 2.dp,
color = accentColor color = accentColor
) )
@@ -1241,12 +1243,12 @@ fun GroupInfoScreen(
val identiconKey = encryptionKey.ifBlank { dialogPublicKey } val identiconKey = encryptionKey.ifBlank { dialogPublicKey }
Box( Box(
modifier = Modifier modifier = Modifier
.size(34.dp) .size(groupMenuTrailingIconSize)
.clip(RoundedCornerShape(6.dp)) .clip(RoundedCornerShape(3.dp))
) { ) {
TelegramStyleIdenticon( TelegramStyleIdenticon(
keyRender = identiconKey, keyRender = identiconKey,
size = 34.dp, size = groupMenuTrailingIconSize,
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme
) )
} }
@@ -1565,11 +1567,12 @@ fun GroupInfoScreen(
) )
} }
AnimatedVisibility( SwipeBackContainer(
visible = showEncryptionPage, isVisible = showEncryptionPage,
enter = fadeIn(animationSpec = tween(durationMillis = 260)), onBack = { showEncryptionPage = false },
exit = fadeOut(animationSpec = tween(durationMillis = 200)), isDarkTheme = isDarkTheme,
modifier = Modifier.fillMaxSize() layer = 3,
propagateBackgroundProgress = false
) { ) {
val displayLines = remember(encryptionKey) { encodeGroupKeyForDisplay(encryptionKey) } val displayLines = remember(encryptionKey) { encodeGroupKeyForDisplay(encryptionKey) }
GroupEncryptionKeyPage( GroupEncryptionKeyPage(

View File

@@ -59,6 +59,7 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.PopupProperties 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) 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 @Composable
fun DateHeader(dateText: String, secondaryTextColor: Color) { fun DateHeader(
var isVisible by remember { mutableStateOf(false) } dateText: String,
val alpha by textColor: Color,
animateFloatAsState( backgroundColor: Color,
targetValue = if (isVisible) 1f else 0f, modifier: Modifier = Modifier,
animationSpec = tween(durationMillis = 250, easing = TelegramEasing), verticalPadding: Dp = 12.dp
label = "dateAlpha" ) {
)
LaunchedEffect(dateText) { isVisible = true }
Row( Row(
modifier = modifier =
Modifier.fillMaxWidth().padding(vertical = 12.dp).graphicsLayer { modifier
this.alpha = alpha .fillMaxWidth()
}, .padding(vertical = verticalPadding),
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
Text( Text(
text = dateText, text = dateText,
fontSize = 13.sp, fontSize = 12.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = secondaryTextColor, color = textColor,
modifier = modifier =
Modifier.background( Modifier.background(
color = secondaryTextColor.copy(alpha = 0.1f), color = backgroundColor,
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(10.dp)
) )
.padding(horizontal = 12.dp, vertical = 4.dp) .padding(horizontal = 10.dp, vertical = 3.dp)
) )
} }
} }