Compare commits

...

5 Commits

18 changed files with 2112 additions and 300 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.1.7" val rosettaVersionName = "1.1.8"
val rosettaVersionCode = 19 // Increment on each release val rosettaVersionCode = 20 // Increment on each release
android { android {
namespace = "com.rosetta.messenger" namespace = "com.rosetta.messenger"

View File

@@ -17,14 +17,22 @@ object ReleaseNotes {
val RELEASE_NOTICE = """ val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER Update v$VERSION_PLACEHOLDER
Уведомления Полноэкранное фото из медиапикера
- Исправлена регистрация push-токена после переподключений - Переработан fullscreen-оверлей: фото открывается поверх чата и перекрывает интерфейс
- Добавлен fallback для нестандартных payload, чтобы push-уведомления не терялись - Добавлены свайпы влево/вправо для перехода по фото внутри выбранной галереи
- Улучшена отправка push-токена сразу после получения FCM токена - Добавлено закрытие свайпом вверх/вниз с плавной анимацией
- Убраны рывки, мигание и лишнее уменьшение фото при перелистывании
Интерфейс Редактирование и отправка
- Улучшено поведение сворачивания приложения в стиле Telegram - Инструменты редактирования фото перенесены в полноэкранный оверлей медиапикера
- Стабилизировано отображение нижней системной панели навигации - Улучшена пересылка фото через optimistic UI: сообщение отображается сразу
- Исправлена множественная пересылка сообщений, включая сценарий после смены forwarding options
- Исправлено копирование пересланных сообщений: теперь корректно копируется текст forward/reply
Группы
- В списках участников групп отображается только статус online/offline
- На экране создания группы у текущего пользователя статус отображается как online
- Поиск участников по username сохранен
""".trimIndent() """.trimIndent()
fun getNotice(version: String): String = fun getNotice(version: String): String =

View File

@@ -75,6 +75,7 @@ import androidx.compose.ui.unit.IntOffset
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.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.zIndex
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -137,6 +138,28 @@ private data class IncomingRunAvatarUiState(
val overlays: List<IncomingRunAvatarOverlay> val overlays: List<IncomingRunAvatarOverlay>
) )
private fun extractCopyableMessageText(message: ChatMessage): String {
val directText = message.text.trim()
if (directText.isNotEmpty()) {
return directText
}
val forwardedText =
message.forwardedMessages
.mapNotNull { forwarded -> forwarded.text.trim().takeIf { it.isNotEmpty() } }
.joinToString("\n\n")
if (forwardedText.isNotEmpty()) {
return forwardedText
}
val replyText = message.replyData?.text?.trim().orEmpty()
if (replyText.isNotEmpty()) {
return replyText
}
return ""
}
@OptIn( @OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
androidx.compose.foundation.ExperimentalFoundationApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class,
@@ -162,6 +185,9 @@ fun ChatDetailScreen(
) { ) {
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}") val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
val context = LocalContext.current val context = LocalContext.current
val hasNativeNavigationBar = remember(context) {
com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)
}
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
@@ -290,6 +316,11 @@ fun ChatDetailScreen(
var imageViewerInitialIndex by remember { mutableStateOf(0) } var imageViewerInitialIndex by remember { mutableStateOf(0) }
var imageViewerSourceBounds by remember { mutableStateOf<ImageSourceBounds?>(null) } var imageViewerSourceBounds by remember { mutableStateOf<ImageSourceBounds?>(null) }
var imageViewerImages by remember { mutableStateOf<List<ViewableImage>>(emptyList()) } var imageViewerImages by remember { mutableStateOf<List<ViewableImage>>(emptyList()) }
var simplePickerPreviewUri by remember { mutableStateOf<Uri?>(null) }
var simplePickerPreviewSourceThumb by remember { mutableStateOf<ThumbnailPosition?>(null) }
var simplePickerPreviewCaption by remember { mutableStateOf("") }
var simplePickerPreviewGalleryUris by remember { mutableStateOf<List<Uri>>(emptyList()) }
var simplePickerPreviewInitialIndex by remember { mutableIntStateOf(0) }
// 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме // 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме
if (!view.isInEditMode) { if (!view.isInEditMode) {
@@ -364,7 +395,8 @@ fun ChatDetailScreen(
showEmojiPicker, showEmojiPicker,
pendingCameraPhotoUri, pendingCameraPhotoUri,
pendingGalleryImages, pendingGalleryImages,
showInAppCamera showInAppCamera,
simplePickerPreviewUri
) { ) {
derivedStateOf { derivedStateOf {
showImageViewer || showImageViewer ||
@@ -372,7 +404,8 @@ fun ChatDetailScreen(
showEmojiPicker || showEmojiPicker ||
pendingCameraPhotoUri != null || pendingCameraPhotoUri != null ||
pendingGalleryImages.isNotEmpty() || pendingGalleryImages.isNotEmpty() ||
showInAppCamera showInAppCamera ||
simplePickerPreviewUri != null
} }
} }
@@ -380,6 +413,13 @@ fun ChatDetailScreen(
onImageViewerChanged(shouldLockParentSwipeBack) onImageViewerChanged(shouldLockParentSwipeBack)
} }
LaunchedEffect(simplePickerPreviewUri) {
if (simplePickerPreviewUri != null) {
showContextMenu = false
contextMenuMessage = null
}
}
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onImageViewerChanged(false) } onDispose { onImageViewerChanged(false) }
} }
@@ -1189,11 +1229,16 @@ fun ChatDetailScreen(
{ it.id } { it.id }
) )
) )
.joinToString( .mapNotNull {
"\n\n"
) {
msg msg
-> ->
val messageText =
extractCopyableMessageText(
msg
)
if (messageText.isBlank()) {
null
} else {
val time = val time =
SimpleDateFormat( SimpleDateFormat(
"HH:mm", "HH:mm",
@@ -1202,8 +1247,13 @@ fun ChatDetailScreen(
.format( .format(
msg.timestamp msg.timestamp
) )
"[${if (msg.isOutgoing) "You" else chatTitle}] $time\n${msg.text}" "[${if (msg.isOutgoing) "You" else chatTitle}] $time\n$messageText"
} }
}
.joinToString(
"\n\n"
)
if (textToCopy.isNotBlank()) {
clipboardManager clipboardManager
.setText( .setText(
androidx.compose androidx.compose
@@ -1213,6 +1263,7 @@ fun ChatDetailScreen(
textToCopy textToCopy
) )
) )
}
selectedMessages = selectedMessages =
emptySet() emptySet()
} }
@@ -1801,7 +1852,10 @@ fun ChatDetailScreen(
bottom = bottom =
16.dp 16.dp
) )
.navigationBarsPadding() .then(
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
else Modifier
)
.graphicsLayer { .graphicsLayer {
scaleX = scaleX =
buttonScale buttonScale
@@ -1976,13 +2030,6 @@ fun ChatDetailScreen(
AttachmentType AttachmentType
.MESSAGES .MESSAGES
} }
.map {
attachment ->
attachment.copy(
localUri =
""
)
}
) )
} }
} else { } else {
@@ -2015,13 +2062,6 @@ fun ChatDetailScreen(
AttachmentType AttachmentType
.MESSAGES .MESSAGES
} }
.map {
attachment ->
attachment.copy(
localUri =
""
)
}
)) ))
} }
} }
@@ -2130,6 +2170,47 @@ fun ChatDetailScreen(
viewModel viewModel
.clearReplyMessages() .clearReplyMessages()
}, },
onShowForwardOptions = { panelMessages ->
if (panelMessages.isEmpty()) {
return@MessageInputBar
}
val forwardMessages =
panelMessages.map { msg ->
ForwardManager.ForwardMessage(
messageId =
msg.messageId,
text = msg.text,
timestamp =
msg.timestamp,
isOutgoing =
msg.isOutgoing,
senderPublicKey =
msg.publicKey.ifEmpty {
if (msg.isOutgoing) currentUserPublicKey
else user.publicKey
},
originalChatPublicKey =
user.publicKey,
senderName =
msg.senderName.ifEmpty {
if (msg.isOutgoing) currentUserName.ifEmpty { "You" }
else user.title.ifEmpty { user.username.ifEmpty { "User" } }
},
attachments =
msg.attachments
.filter {
it.type != AttachmentType.MESSAGES
}
.map { it.copy(localUri = "") }
)
}
ForwardManager.setForwardMessages(
forwardMessages,
showPicker = false
)
showForwardPicker = true
},
chatTitle = chatTitle, chatTitle = chatTitle,
isBlocked = isBlocked, isBlocked = isBlocked,
showEmojiPicker = showEmojiPicker =
@@ -2168,7 +2249,9 @@ fun ChatDetailScreen(
inputFocusTrigger = inputFocusTrigger =
inputFocusTrigger, inputFocusTrigger,
suppressKeyboard = suppressKeyboard =
showInAppCamera showInAppCamera,
hasNativeNavigationBar =
hasNativeNavigationBar
) )
} }
} }
@@ -2513,6 +2596,9 @@ fun ChatDetailScreen(
avatarRepository = avatarRepository =
avatarRepository, avatarRepository,
onLongClick = { onLongClick = {
if (simplePickerPreviewUri != null) {
return@MessageBubble
}
// 📳 Haptic feedback при долгом нажатии // 📳 Haptic feedback при долгом нажатии
// Не разрешаем выделять avatar-сообщения // Не разрешаем выделять avatar-сообщения
val hasAvatar = val hasAvatar =
@@ -2553,6 +2639,9 @@ fun ChatDetailScreen(
) )
}, },
onClick = { onClick = {
if (simplePickerPreviewUri != null) {
return@MessageBubble
}
if (shouldIgnoreTapAfterLongPress( if (shouldIgnoreTapAfterLongPress(
selectionKey selectionKey
) )
@@ -2743,7 +2832,7 @@ fun ChatDetailScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
isPinned = contextMenuIsPinned, isPinned = contextMenuIsPinned,
isOutgoing = msg.isOutgoing, isOutgoing = msg.isOutgoing,
hasText = msg.text.isNotBlank(), hasText = extractCopyableMessageText(msg).isNotBlank(),
isSystemAccount = isSystemAccount, isSystemAccount = isSystemAccount,
onReply = { onReply = {
viewModel.setReplyMessages(listOf(msg)) viewModel.setReplyMessages(listOf(msg))
@@ -2752,7 +2841,7 @@ fun ChatDetailScreen(
}, },
onCopy = { onCopy = {
clipboardManager.setText( clipboardManager.setText(
androidx.compose.ui.text.AnnotatedString(msg.text) androidx.compose.ui.text.AnnotatedString(extractCopyableMessageText(msg))
) )
showContextMenu = false showContextMenu = false
contextMenuMessage = null contextMenuMessage = null
@@ -3005,7 +3094,24 @@ fun ChatDetailScreen(
onAvatarClick = { onAvatarClick = {
viewModel.sendAvatarMessage() viewModel.sendAvatarMessage()
}, },
recipientName = user.title recipientName = user.title,
onPhotoPreviewRequested = { uri, sourceThumb, galleryUris, initialIndex ->
hideInputOverlays()
showMediaPicker = false
showContextMenu = false
contextMenuMessage = null
simplePickerPreviewSourceThumb = sourceThumb
simplePickerPreviewCaption = ""
simplePickerPreviewUri = uri
val normalizedGallery =
if (galleryUris.isNotEmpty()) galleryUris else listOf(uri)
simplePickerPreviewGalleryUris = normalizedGallery
simplePickerPreviewInitialIndex =
initialIndex.coerceIn(
0,
(normalizedGallery.size - 1).coerceAtLeast(0)
)
}
) )
} else { } else {
MediaPickerBottomSheet( MediaPickerBottomSheet(
@@ -3049,7 +3155,24 @@ fun ChatDetailScreen(
onAvatarClick = { onAvatarClick = {
viewModel.sendAvatarMessage() viewModel.sendAvatarMessage()
}, },
recipientName = user.title recipientName = user.title,
onPhotoPreviewRequested = { uri, sourceThumb, galleryUris, initialIndex ->
hideInputOverlays()
showMediaPicker = false
showContextMenu = false
contextMenuMessage = null
simplePickerPreviewSourceThumb = sourceThumb
simplePickerPreviewCaption = ""
simplePickerPreviewUri = uri
val normalizedGallery =
if (galleryUris.isNotEmpty()) galleryUris else listOf(uri)
simplePickerPreviewGalleryUris = normalizedGallery
simplePickerPreviewInitialIndex =
initialIndex.coerceIn(
0,
(normalizedGallery.size - 1).coerceAtLeast(0)
)
}
) )
} }
} // Закрытие Box wrapper для Scaffold content } // Закрытие Box wrapper для Scaffold content
@@ -3282,12 +3405,40 @@ fun ChatDetailScreen(
} }
val forwardMessages = ForwardManager.consumeForwardMessages() val forwardMessages = ForwardManager.consumeForwardMessages()
ForwardManager.clear()
if (forwardMessages.isEmpty()) { if (forwardMessages.isEmpty()) {
ForwardManager.clear()
return@ForwardChatPickerBottomSheet return@ForwardChatPickerBottomSheet
} }
// Реальная отправка forward во все выбранные чаты. // Desktop parity: если выбран один чат, не отправляем сразу.
// Открываем чат с forward-панелью, чтобы пользователь мог
// добавить подпись и отправить вручную.
if (selectedDialogs.size == 1) {
val targetDialog = selectedDialogs.first()
ForwardManager.setForwardMessages(
forwardMessages,
showPicker = false
)
ForwardManager.selectChat(targetDialog.opponentKey)
if (targetDialog.opponentKey != user.publicKey) {
val searchUser =
SearchUser(
title = targetDialog.opponentTitle,
username =
targetDialog.opponentUsername,
publicKey = targetDialog.opponentKey,
verified = targetDialog.verified,
online = targetDialog.isOnline
)
onNavigateToChat(searchUser)
}
return@ForwardChatPickerBottomSheet
}
ForwardManager.clear()
// Мультивыбор оставляем прямой отправкой как раньше.
selectedDialogs.forEach { dialog -> selectedDialogs.forEach { dialog ->
viewModel.sendForwardDirectly( viewModel.sendForwardDirectly(
dialog.opponentKey, dialog.opponentKey,
@@ -3314,6 +3465,38 @@ fun ChatDetailScreen(
} // Закрытие Scaffold content lambda } // Закрытие Scaffold content lambda
simplePickerPreviewUri?.let { previewUri ->
SimpleFullscreenPhotoOverlay(
imageUri = previewUri,
sourceThumbnail = simplePickerPreviewSourceThumb,
galleryImageUris = simplePickerPreviewGalleryUris,
initialGalleryIndex = simplePickerPreviewInitialIndex,
modifier = Modifier.fillMaxSize().zIndex(100f),
showCaptionInput = true,
caption = simplePickerPreviewCaption,
onCaptionChange = { simplePickerPreviewCaption = it },
isDarkTheme = isDarkTheme,
onSend = { editedUri, caption ->
viewModel.sendImageFromUri(editedUri, caption)
showMediaPicker = false
simplePickerPreviewUri = null
simplePickerPreviewSourceThumb = null
simplePickerPreviewCaption = ""
simplePickerPreviewGalleryUris = emptyList()
simplePickerPreviewInitialIndex = 0
inputFocusTrigger++
},
onDismiss = {
simplePickerPreviewUri = null
simplePickerPreviewSourceThumb = null
simplePickerPreviewCaption = ""
simplePickerPreviewGalleryUris = emptyList()
simplePickerPreviewInitialIndex = 0
inputFocusTrigger++
}
)
}
// <20> Image Viewer Overlay — FULLSCREEN поверх Scaffold // <20> Image Viewer Overlay — FULLSCREEN поверх Scaffold
if (showImageViewer && imageViewerImages.isNotEmpty()) { if (showImageViewer && imageViewerImages.isNotEmpty()) {
ImageViewerScreen( ImageViewerScreen(

View File

@@ -1582,10 +1582,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val attBlob = attJson.optString("blob", "") val attBlob = attJson.optString("blob", "")
val attWidth = attJson.optInt("width", 0) val attWidth = attJson.optInt("width", 0)
val attHeight = attJson.optInt("height", 0) val attHeight = attJson.optInt("height", 0)
val attLocalUri = attJson.optString("localUri", "")
if (attId.isNotEmpty()) { if (attId.isNotEmpty()) {
fwdAttachments.add(MessageAttachment( fwdAttachments.add(MessageAttachment(
id = attId, type = attType, preview = attPreview, id = attId, type = attType, preview = attPreview,
blob = attBlob, width = attWidth, height = attHeight blob = attBlob, width = attWidth, height = attHeight,
localUri = attLocalUri
)) ))
} }
} }
@@ -1662,6 +1664,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val attBlob = attJson.optString("blob", "") val attBlob = attJson.optString("blob", "")
val attWidth = attJson.optInt("width", 0) val attWidth = attJson.optInt("width", 0)
val attHeight = attJson.optInt("height", 0) val attHeight = attJson.optInt("height", 0)
val attLocalUri = attJson.optString("localUri", "")
if (attId.isNotEmpty()) { if (attId.isNotEmpty()) {
replyAttachmentsFromJson.add( replyAttachmentsFromJson.add(
@@ -1671,7 +1674,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
preview = attPreview, preview = attPreview,
blob = attBlob, blob = attBlob,
width = attWidth, width = attWidth,
height = attHeight height = attHeight,
localUri = attLocalUri
) )
) )
} }
@@ -2239,6 +2243,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val sender = myPublicKey val sender = myPublicKey
val privateKey = myPrivateKey val privateKey = myPrivateKey
val replyMsgs = _replyMessages.value val replyMsgs = _replyMessages.value
val replyMsgsToSend = replyMsgs.toList()
val isForward = _isForwardMode.value val isForward = _isForwardMode.value
// Разрешаем отправку пустого текста если есть reply/forward // Разрешаем отправку пустого текста если есть reply/forward
@@ -2267,10 +2272,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val timestamp = System.currentTimeMillis() val timestamp = System.currentTimeMillis()
// 🔥 Формируем ReplyData для отображения в UI (только первое сообщение) // 🔥 Формируем ReplyData для отображения в UI (только первое сообщение)
// Работает и для reply, и для forward // Используется для обычного reply (не forward).
val replyData: ReplyData? = val replyData: ReplyData? =
if (replyMsgs.isNotEmpty()) { if (replyMsgsToSend.isNotEmpty()) {
val firstReply = replyMsgs.first() val firstReply = replyMsgsToSend.first()
// 🖼️ Получаем attachments из текущих сообщений для превью // 🖼️ Получаем attachments из текущих сообщений для превью
// Fallback на firstReply.attachments для forward из другого чата // Fallback на firstReply.attachments для forward из другого чата
val replyAttachments = val replyAttachments =
@@ -2298,8 +2303,38 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
) )
} else null } else null
// Сохраняем reply для отправки ПЕРЕД очисткой // 📨 В forward режиме показываем ВСЕ пересылаемые сообщения в optimistic bubble,
val replyMsgsToSend = replyMsgs.toList() // а не только первое. Иначе визуально выглядит как будто отправилось одно сообщение.
val optimisticForwardedMessages: List<ReplyData> =
if (isForward && replyMsgsToSend.isNotEmpty()) {
replyMsgsToSend.map { msg ->
val senderDisplayName =
if (msg.isOutgoing) "You"
else msg.senderName.ifEmpty {
opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
}
val resolvedAttachments =
_messages.value.find { it.id == msg.messageId }?.attachments
?: msg.attachments.filter { it.type != AttachmentType.MESSAGES }
ReplyData(
messageId = msg.messageId,
senderName = senderDisplayName,
text = msg.text,
isFromMe = msg.isOutgoing,
isForwarded = true,
forwardedFromName = senderDisplayName,
attachments = resolvedAttachments,
senderPublicKey = msg.publicKey.ifEmpty {
if (msg.isOutgoing) myPublicKey ?: "" else opponentKey ?: ""
},
recipientPrivateKey = myPrivateKey ?: ""
)
}
} else {
emptyList()
}
// Сохраняем режим forward для отправки ПЕРЕД очисткой
val isForwardToSend = isForward val isForwardToSend = isForward
// 1. 🚀 Optimistic UI - мгновенно показываем сообщение с reply bubble // 1. 🚀 Optimistic UI - мгновенно показываем сообщение с reply bubble
@@ -2310,7 +2345,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isOutgoing = true, isOutgoing = true,
timestamp = Date(timestamp), timestamp = Date(timestamp),
status = MessageStatus.SENDING, status = MessageStatus.SENDING,
replyData = replyData // Данные для reply bubble replyData = if (isForwardToSend) null else replyData,
forwardedMessages = optimisticForwardedMessages
) )
// <20> Безопасное добавление с проверкой дубликатов // <20> Безопасное добавление с проверкой дубликатов
@@ -2566,12 +2602,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val privateKey = myPrivateKey ?: return val privateKey = myPrivateKey ?: return
if (forwardMessages.isEmpty()) return if (forwardMessages.isEmpty()) return
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val isCurrentDialogTarget = recipientPublicKey == opponentKey
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val context = getApplication<Application>() val context = getApplication<Application>()
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val isSavedMessages = (sender == recipientPublicKey) val isSavedMessages = (sender == recipientPublicKey)
val db = RosettaDatabase.getDatabase(context)
val dialogDao = db.dialogDao()
suspend fun refreshTargetDialog() {
if (isSavedMessages) {
dialogDao.updateSavedMessagesDialogFromMessages(sender)
} else {
dialogDao.updateDialogFromMessages(sender, recipientPublicKey)
}
}
// Шифрование (пустой текст для forward) // Шифрование (пустой текст для forward)
val encryptionContext = val encryptionContext =
@@ -2584,11 +2632,133 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val encryptedKey = encryptionContext.encryptedKey val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val replyAttachmentId = "reply_${timestamp}"
val messageAttachments = mutableListOf<MessageAttachment>() fun buildForwardReplyJson(
var replyBlobForDatabase = "" forwardedIdMap: Map<String, Pair<String, String>> = emptyMap(),
includeLocalUri: Boolean
): JSONArray {
val replyJsonArray = JSONArray()
forwardMessages.forEach { fm ->
val attachmentsArray = JSONArray()
fm.attachments.forEach { att ->
val fwdInfo = forwardedIdMap[att.id]
val attId = fwdInfo?.first ?: att.id
val attPreview = fwdInfo?.second ?: att.preview
// 📸 Forward: сначала загружаем IMAGE на CDN, чтобы обновить ссылки в MESSAGES blob attachmentsArray.put(
JSONObject().apply {
put("id", attId)
put("type", att.type.value)
put("preview", attPreview)
put("width", att.width)
put("height", att.height)
put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "")
if (includeLocalUri && att.localUri.isNotEmpty()) {
put("localUri", att.localUri)
}
}
)
}
replyJsonArray.put(
JSONObject().apply {
put("message_id", fm.messageId)
put("publicKey", fm.senderPublicKey)
put("message", fm.text)
put("timestamp", fm.timestamp)
put("attachments", attachmentsArray)
put("forwarded", true)
put("senderName", fm.senderName)
}
)
}
return replyJsonArray
}
// 1) 🚀 Optimistic forward: мгновенно показываем сообщение в текущем диалоге
if (isCurrentDialogTarget) {
val optimisticForwardedMessages =
forwardMessages.map { fm ->
val senderDisplayName =
fm.senderName.ifEmpty {
if (fm.senderPublicKey == sender) "You" else "User"
}
ReplyData(
messageId = fm.messageId,
senderName = senderDisplayName,
text = fm.text,
isFromMe = fm.senderPublicKey == sender,
isForwarded = true,
forwardedFromName = senderDisplayName,
attachments = fm.attachments.filter { it.type != AttachmentType.MESSAGES },
senderPublicKey = fm.senderPublicKey,
recipientPrivateKey = privateKey
)
}
withContext(Dispatchers.Main) {
addMessageSafely(
ChatMessage(
id = messageId,
text = "",
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
forwardedMessages = optimisticForwardedMessages
)
)
}
}
// 2) 💾 Optimistic запись в БД (до загрузки файлов), чтобы сообщение было видно сразу
val optimisticReplyBlobPlaintext =
buildForwardReplyJson(includeLocalUri = true).toString()
val optimisticReplyBlobForDatabase =
CryptoManager.encryptWithPassword(optimisticReplyBlobPlaintext, privateKey)
val optimisticAttachmentsJson =
JSONArray()
.apply {
put(
JSONObject().apply {
put("id", replyAttachmentId)
put("type", AttachmentType.MESSAGES.value)
put("preview", "")
put("width", 0)
put("height", 0)
put("blob", optimisticReplyBlobForDatabase)
}
)
}
.toString()
saveMessageToDatabase(
messageId = messageId,
text = "",
encryptedContent = encryptedContent,
encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
isFromMe = true,
delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = optimisticAttachmentsJson,
opponentPublicKey = recipientPublicKey
)
refreshTargetDialog()
if (isSavedMessages && isCurrentDialogTarget) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
}
// 📸 Forward: загружаем IMAGE на CDN и пересобираем MESSAGES blob с новыми ID/tag
// Map: originalAttId → (newAttId, newPreview) // Map: originalAttId → (newAttId, newPreview)
val forwardedAttMap = mutableMapOf<String, Pair<String, String>>() val forwardedAttMap = mutableMapOf<String, Pair<String, String>>()
var fwdIdx = 0 var fwdIdx = 0
@@ -2631,47 +2801,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
// Формируем MESSAGES attachment (reply/forward JSON) с обновлёнными ссылками val replyBlobPlaintext =
val replyJsonArray = JSONArray() buildForwardReplyJson(
forwardMessages.forEach { fm -> forwardedIdMap = forwardedAttMap,
val attachmentsArray = JSONArray() includeLocalUri = false
fm.attachments.forEach { att -> )
// Для forward IMAGE: подставляем НОВЫЙ id и preview (CDN tag) .toString()
val fwdInfo = forwardedAttMap[att.id]
val attId = fwdInfo?.first ?: att.id
val attPreview = fwdInfo?.second ?: att.preview
attachmentsArray.put(JSONObject().apply {
put("id", attId)
put("type", att.type.value)
put("preview", attPreview)
put("width", att.width)
put("height", att.height)
put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "")
})
}
replyJsonArray.put(JSONObject().apply {
put("message_id", fm.messageId)
put("publicKey", fm.senderPublicKey)
put("message", fm.text)
put("timestamp", fm.timestamp)
put("attachments", attachmentsArray)
put("forwarded", true)
put("senderName", fm.senderName)
})
}
val replyBlobPlaintext = replyJsonArray.toString()
val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext) val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext)
replyBlobForDatabase = CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey) val replyBlobForDatabase =
CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
val replyAttachmentId = "reply_${timestamp}" val finalMessageAttachments =
messageAttachments.add(MessageAttachment( listOf(
MessageAttachment(
id = replyAttachmentId, id = replyAttachmentId,
blob = encryptedReplyBlob, blob = encryptedReplyBlob,
type = AttachmentType.MESSAGES, type = AttachmentType.MESSAGES,
preview = "" preview = ""
)) )
)
// Отправляем пакет // Отправляем пакет
val packet = PacketMessage().apply { val packet = PacketMessage().apply {
@@ -2683,58 +2831,57 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
this.timestamp = timestamp this.timestamp = timestamp
this.privateKey = privateKeyHash this.privateKey = privateKeyHash
this.messageId = messageId this.messageId = messageId
attachments = messageAttachments attachments = finalMessageAttachments
} }
if (!isSavedMessages) { if (!isSavedMessages) {
ProtocolManager.send(packet) ProtocolManager.send(packet)
} }
// Сохраняем в БД val finalAttachmentsJson =
val attachmentsJson = JSONArray().apply { JSONArray()
messageAttachments.forEach { att -> .apply {
put(JSONObject().apply { finalMessageAttachments.forEach { att ->
put(
JSONObject().apply {
put("id", att.id) put("id", att.id)
put("type", att.type.value) put("type", att.type.value)
put("preview", att.preview) put("preview", att.preview)
put("width", att.width) put("width", att.width)
put("height", att.height) put("height", att.height)
put("blob", when (att.type) { put(
AttachmentType.MESSAGES -> replyBlobForDatabase "blob",
when (att.type) {
AttachmentType.MESSAGES ->
replyBlobForDatabase
else -> "" else -> ""
})
})
} }
}.toString() )
}
)
}
}
.toString()
saveMessageToDatabase( updateMessageStatusAndAttachmentsInDb(
messageId = messageId, messageId = messageId,
text = "", delivered = 1,
encryptedContent = encryptedContent, attachmentsJson = finalAttachmentsJson
encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
isFromMe = true,
delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = attachmentsJson,
opponentPublicKey = recipientPublicKey
) )
// Обновляем диалог (для списка чатов) из таблицы сообщений. if (isCurrentDialogTarget) {
val db = RosettaDatabase.getDatabase(context) withContext(Dispatchers.Main) {
val dialogDao = db.dialogDao() updateMessageStatus(messageId, MessageStatus.SENT)
if (isSavedMessages) { }
dialogDao.updateSavedMessagesDialogFromMessages(sender) }
} else { refreshTargetDialog()
dialogDao.updateDialogFromMessages(sender, recipientPublicKey) } catch (e: Exception) {
if (isCurrentDialogTarget) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.ERROR)
}
}
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
} }
} catch (e: Exception) { }
} }
} }

View File

@@ -273,6 +273,9 @@ fun ChatsListScreen(
val view = androidx.compose.ui.platform.LocalView.current val view = androidx.compose.ui.platform.LocalView.current
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
val hasNativeNavigationBar = remember(context) {
com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)
}
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -443,10 +446,6 @@ fun ChatsListScreen(
insetsController.isAppearanceLightStatusBars = false insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT window.statusBarColor = android.graphics.Color.TRANSPARENT
// Navigation bar
com.rosetta.messenger.ui.utils.NavigationModeUtils
.applyNavigationBarVisibility(insetsController, context, isDarkTheme)
onDispose { } onDispose { }
} }
@@ -754,7 +753,10 @@ fun ChatsListScreen(
Modifier.fillMaxSize() Modifier.fillMaxSize()
.onSizeChanged { rootSize = it } .onSizeChanged { rootSize = it }
.background(backgroundColor) .background(backgroundColor)
.navigationBarsPadding() .then(
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
else Modifier
)
) { ) {
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
@@ -812,6 +814,15 @@ fun ChatsListScreen(
"rosetta", "rosetta",
ignoreCase = true ignoreCase = true
) )
val isFreddyOfficial =
accountName.equals(
"freddy",
ignoreCase = true
) ||
accountUsername.equals(
"freddy",
ignoreCase = true
)
// Avatar row with theme toggle // Avatar row with theme toggle
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -925,7 +936,7 @@ fun ChatsListScreen(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.White color = Color.White
) )
if (accountVerified > 0 || isRosettaOfficial) { if (accountVerified > 0 || isRosettaOfficial || isFreddyOfficial) {
Spacer( Spacer(
modifier = modifier =
Modifier.width( Modifier.width(
@@ -935,7 +946,7 @@ fun ChatsListScreen(
VerifiedBadge( VerifiedBadge(
verified = if (accountVerified > 0) accountVerified else 1, verified = if (accountVerified > 0) accountVerified else 1,
size = 15, size = 15,
badgeTint = PrimaryBlue badgeTint = if (isDarkTheme) Color.White else PrimaryBlue
) )
} }
} }
@@ -1230,7 +1241,14 @@ fun ChatsListScreen(
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// FOOTER - Version + Update Banner // FOOTER - Version + Update Banner
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) { Column(
modifier =
Modifier.fillMaxWidth()
.then(
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
else Modifier
)
) {
// Telegram-style update banner // Telegram-style update banner
val curUpdate = sduUpdateState val curUpdate = sduUpdateState
val showUpdateBanner = curUpdate is UpdateState.UpdateAvailable || val showUpdateBanner = curUpdate is UpdateState.UpdateAvailable ||
@@ -3886,7 +3904,10 @@ fun DialogItemContent(
val isRosettaOfficial = dialog.opponentTitle.equals("Rosetta", ignoreCase = true) || val isRosettaOfficial = dialog.opponentTitle.equals("Rosetta", ignoreCase = true) ||
dialog.opponentUsername.equals("rosetta", ignoreCase = true) || dialog.opponentUsername.equals("rosetta", ignoreCase = true) ||
MessageRepository.isSystemAccount(dialog.opponentKey) MessageRepository.isSystemAccount(dialog.opponentKey)
if (dialog.verified > 0 || isRosettaOfficial) { val isFreddyVerified = dialog.opponentUsername.equals("freddy", ignoreCase = true) ||
dialog.opponentTitle.equals("freddy", ignoreCase = true) ||
displayName.equals("freddy", ignoreCase = true)
if (dialog.verified > 0 || isRosettaOfficial || isFreddyVerified) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge( VerifiedBadge(
verified = if (dialog.verified > 0) dialog.verified else 1, verified = if (dialog.verified > 0) dialog.verified else 1,

View File

@@ -737,11 +737,7 @@ fun GroupInfoScreen(
info?.title?.takeIf { it.isNotBlank() } info?.title?.takeIf { it.isNotBlank() }
?: info?.username?.takeIf { it.isNotBlank() } ?: info?.username?.takeIf { it.isNotBlank() }
?: fallbackName ?: fallbackName
val subtitle = when { val subtitle = if (isOnline) "online" else "offline"
isOnline -> "online"
info?.username?.isNotBlank() == true -> "@${info.username}"
else -> key.take(18)
}
GroupMemberUi( GroupMemberUi(
publicKey = key, publicKey = key,
title = displayTitle, title = displayTitle,
@@ -761,9 +757,11 @@ fun GroupInfoScreen(
if (query.isBlank()) { if (query.isBlank()) {
true true
} else { } else {
val username = memberInfoByKey[member.publicKey]?.username?.lowercase().orEmpty()
member.title.lowercase().contains(query) || member.title.lowercase().contains(query) ||
member.subtitle.lowercase().contains(query) || member.subtitle.lowercase().contains(query) ||
member.publicKey.lowercase().contains(query) member.publicKey.lowercase().contains(query) ||
username.contains(query)
} }
} }
} }

View File

@@ -183,7 +183,7 @@ fun GroupSetupScreen(
.ifBlank { normalizedUsername } .ifBlank { normalizedUsername }
.ifBlank { shortPublicKey(accountPublicKey) } .ifBlank { shortPublicKey(accountPublicKey) }
} }
val selfSubtitle = if (normalizedUsername.isNotBlank()) "@$normalizedUsername" else "you" val selfSubtitle = "online"
fun openGroup(dialogPublicKey: String, groupTitle: String) { fun openGroup(dialogPublicKey: String, groupTitle: String) {
onGroupOpened( onGroupOpened(

View File

@@ -38,6 +38,7 @@ import androidx.core.view.WindowCompat
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.rosetta.messenger.ui.chats.components.ImageEditorScreen import com.rosetta.messenger.ui.chats.components.ImageEditorScreen
import com.rosetta.messenger.ui.chats.components.PhotoPreviewWithCaptionScreen import com.rosetta.messenger.ui.chats.components.PhotoPreviewWithCaptionScreen
import com.rosetta.messenger.ui.chats.components.SimpleFullscreenPhotoViewer
import com.rosetta.messenger.ui.chats.components.ThumbnailPosition import com.rosetta.messenger.ui.chats.components.ThumbnailPosition
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
import com.rosetta.messenger.ui.components.KeyboardHeightProvider import com.rosetta.messenger.ui.components.KeyboardHeightProvider
@@ -45,6 +46,7 @@ import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.utils.NavigationModeUtils
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import android.net.Uri import android.net.Uri
@@ -121,6 +123,13 @@ private fun updatePopupImeFocusable(rootView: View, imeFocusable: Boolean) {
} }
} }
private data class PickerSystemBarsSnapshot(
val scrimAlpha: Float,
val isFullScreen: Boolean,
val isDarkTheme: Boolean,
val openProgress: Float
)
/** /**
* Telegram-style attach alert (media picker bottom sheet). * Telegram-style attach alert (media picker bottom sheet).
* *
@@ -146,9 +155,13 @@ fun ChatAttachAlert(
currentUserPublicKey: String = "", currentUserPublicKey: String = "",
maxSelection: Int = 10, maxSelection: Int = 10,
recipientName: String? = null, recipientName: String? = null,
onPhotoPreviewRequested: ((Uri, ThumbnailPosition?, List<Uri>, Int) -> Unit)? = null,
viewModel: AttachAlertViewModel = viewModel() viewModel: AttachAlertViewModel = viewModel()
) { ) {
val context = LocalContext.current val context = LocalContext.current
val hasNativeNavigationBar = remember(context) {
NavigationModeUtils.hasNativeNavigationBar(context)
}
val density = LocalDensity.current val density = LocalDensity.current
val imeInsets = WindowInsets.ime val imeInsets = WindowInsets.ime
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
@@ -195,6 +208,23 @@ fun ChatAttachAlert(
// Keyboard helpers // Keyboard helpers
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
fun hideUnderlyingChatKeyboard() {
focusManager.clearFocus(force = true)
activity?.currentFocus?.clearFocus()
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
val servedToken =
activity?.currentFocus?.windowToken
?: activity?.window?.decorView?.findFocus()?.windowToken
?: activity?.window?.decorView?.windowToken
servedToken?.let { token ->
imm.hideSoftInputFromWindow(token, 0)
imm.hideSoftInputFromWindow(token, InputMethodManager.HIDE_NOT_ALWAYS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
activity?.window?.insetsController?.hide(android.view.WindowInsets.Type.ime())
}
}
fun hideKeyboard() { fun hideKeyboard() {
AttachAlertDebugLog.log("AttachAlert", "hideKeyboard() called, captionInputActive=$captionInputActive, shouldShow=$shouldShow, isClosing=$isClosing") AttachAlertDebugLog.log("AttachAlert", "hideKeyboard() called, captionInputActive=$captionInputActive, shouldShow=$shouldShow, isClosing=$isClosing")
pendingCaptionFocus = false pendingCaptionFocus = false
@@ -268,10 +298,14 @@ fun ChatAttachAlert(
if (coordinator.emojiHeight == 0.dp) { if (coordinator.emojiHeight == 0.dp) {
// Use saved keyboard height minus nav bar (same as spacer) // Use saved keyboard height minus nav bar (same as spacer)
val savedPx = KeyboardHeightProvider.getSavedKeyboardHeight(context) val savedPx = KeyboardHeightProvider.getSavedKeyboardHeight(context)
val navBarPx = (context as? Activity)?.window?.decorView?.let { view -> val navBarPx = if (hasNativeNavigationBar) {
(context as? Activity)?.window?.decorView?.let { view ->
androidx.core.view.ViewCompat.getRootWindowInsets(view) androidx.core.view.ViewCompat.getRootWindowInsets(view)
?.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0 ?.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
} ?: 0 } ?: 0
} else {
0
}
val effectivePx = if (savedPx > 0) (savedPx - navBarPx).coerceAtLeast(0) else 0 val effectivePx = if (savedPx > 0) (savedPx - navBarPx).coerceAtLeast(0) else 0
coordinator.emojiHeight = if (effectivePx > 0) { coordinator.emojiHeight = if (effectivePx > 0) {
with(density) { effectivePx.toDp() } with(density) { effectivePx.toDp() }
@@ -293,7 +327,11 @@ fun ChatAttachAlert(
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp val screenHeight = configuration.screenHeightDp.dp
val screenHeightPx = with(density) { screenHeight.toPx() } val screenHeightPx = with(density) { screenHeight.toPx() }
val navigationBarInsetPx = WindowInsets.navigationBars.getBottom(density).toFloat() val navigationBarInsetPx = if (hasNativeNavigationBar) {
WindowInsets.navigationBars.getBottom(density).toFloat()
} else {
0f
}
val statusBarInsetPx = WindowInsets.statusBars.getTop(density).toFloat() val statusBarInsetPx = WindowInsets.statusBars.getTop(density).toFloat()
val collapsedHeightPx = screenHeightPx * 0.72f val collapsedHeightPx = screenHeightPx * 0.72f
@@ -456,6 +494,9 @@ fun ChatAttachAlert(
LaunchedEffect(showSheet) { LaunchedEffect(showSheet) {
if (showSheet && state.editingItem == null) { if (showSheet && state.editingItem == null) {
// Close chat keyboard before showing picker so no IME layer remains under it.
hideUnderlyingChatKeyboard()
kotlinx.coroutines.delay(16)
// Telegram pattern: set ADJUST_NOTHING on Activity before showing popup // Telegram pattern: set ADJUST_NOTHING on Activity before showing popup
// This prevents the system from resizing the layout when focus changes // This prevents the system from resizing the layout when focus changes
activity?.window?.let { win -> activity?.window?.let { win ->
@@ -668,22 +709,40 @@ fun ChatAttachAlert(
val origNavBarColor = window?.navigationBarColor ?: 0 val origNavBarColor = window?.navigationBarColor ?: 0
val origLightNav = insetsController?.isAppearanceLightNavigationBars ?: true val origLightNav = insetsController?.isAppearanceLightNavigationBars ?: true
val origLightStatus = insetsController?.isAppearanceLightStatusBars ?: false val origLightStatus = insetsController?.isAppearanceLightStatusBars ?: false
val origContrastEnforced =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window?.isNavigationBarContrastEnforced
} else {
null
}
onDispose { onDispose {
window?.statusBarColor = origStatusBarColor window?.statusBarColor = origStatusBarColor
window?.navigationBarColor = origNavBarColor window?.navigationBarColor = origNavBarColor
insetsController?.isAppearanceLightNavigationBars = origLightNav insetsController?.isAppearanceLightNavigationBars = origLightNav
insetsController?.isAppearanceLightStatusBars = origLightStatus insetsController?.isAppearanceLightStatusBars = origLightStatus
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && origContrastEnforced != null) {
window?.isNavigationBarContrastEnforced = origContrastEnforced
}
} }
} else { } else {
onDispose { } onDispose { }
} }
} }
LaunchedEffect(shouldShow) { LaunchedEffect(shouldShow, state.editingItem) {
if (!shouldShow) return@LaunchedEffect if (!shouldShow || state.editingItem != null) return@LaunchedEffect
val window = (view.context as? Activity)?.window ?: return@LaunchedEffect val window = (view.context as? Activity)?.window ?: return@LaunchedEffect
snapshotFlow { Triple(scrimAlpha, isPickerFullScreen, isDarkTheme) } snapshotFlow {
.collect { (alpha, fullScreen, dark) -> PickerSystemBarsSnapshot(
scrimAlpha = scrimAlpha,
isFullScreen = isPickerFullScreen,
isDarkTheme = isDarkTheme,
openProgress = (1f - animatedOffset).coerceIn(0f, 1f)
)
}.collect { state ->
val alpha = state.scrimAlpha
val fullScreen = state.isFullScreen
val dark = state.isDarkTheme
if (fullScreen) { if (fullScreen) {
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt() window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
insetsController?.isAppearanceLightStatusBars = !dark insetsController?.isAppearanceLightStatusBars = !dark
@@ -693,8 +752,28 @@ fun ChatAttachAlert(
window.statusBarColor = android.graphics.Color.argb(scrimInt, 0, 0, 0) window.statusBarColor = android.graphics.Color.argb(scrimInt, 0, 0, 0)
insetsController?.isAppearanceLightStatusBars = false insetsController?.isAppearanceLightStatusBars = false
} }
if (hasNativeNavigationBar) {
// Telegram-like: for 2/3-button navigation keep picker-surface tint.
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
window.navigationBarColor = android.graphics.Color.argb(
navAlpha,
android.graphics.Color.red(navBaseColor),
android.graphics.Color.green(navBaseColor),
android.graphics.Color.blue(navBaseColor)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = true
}
insetsController?.isAppearanceLightNavigationBars = !dark
} else {
// Telegram-like on gesture navigation: transparent stable nav area.
window.navigationBarColor = android.graphics.Color.TRANSPARENT window.navigationBarColor = android.graphics.Color.TRANSPARENT
insetsController?.isAppearanceLightNavigationBars = alpha < 0.15f if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
insetsController?.isAppearanceLightNavigationBars = !dark
}
} }
} }
@@ -710,7 +789,7 @@ fun ChatAttachAlert(
// POPUP RENDERING // POPUP RENDERING
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
if (shouldShow) { if (shouldShow && state.editingItem == null) {
Popup( Popup(
alignment = Alignment.TopStart, alignment = Alignment.TopStart,
onDismissRequest = { onDismissRequest = {
@@ -823,8 +902,9 @@ fun ChatAttachAlert(
} else keyboardSpacerDp } else keyboardSpacerDp
// When keyboard or emoji is open, nav bar is behind — don't pad for it // When keyboard or emoji is open, nav bar is behind — don't pad for it
val navBarDp = if (keyboardSpacerPx > 0f || coordinator.isEmojiBoxVisible) 0.dp val navInsetPxForSheet = if (keyboardSpacerPx > 0f || coordinator.isEmojiBoxVisible) 0f
else with(density) { navigationBarInsetPx.toDp() } else navigationBarInsetPx
val navInsetDpForSheet = with(density) { navInsetPxForSheet.toDp() }
Box( Box(
modifier = Modifier modifier = Modifier
@@ -833,13 +913,12 @@ fun ChatAttachAlert(
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null indication = null
) { requestClose() } ) { requestClose() },
.padding(bottom = navBarDp),
contentAlignment = Alignment.BottomCenter contentAlignment = Alignment.BottomCenter
) { ) {
// Sheet height stays constant — keyboard space is handled by // Sheet height stays constant — keyboard space is handled by
// internal Spacer, not by shrinking the container (Telegram approach). // internal Spacer, not by shrinking the container (Telegram approach).
val visibleSheetHeightPx = sheetHeightPx.value.coerceAtLeast(minHeightPx) val visibleSheetHeightPx = (sheetHeightPx.value + navInsetPxForSheet).coerceAtLeast(minHeightPx)
val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() } val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() }
val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt() val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt()
val expandProgress = val expandProgress =
@@ -976,8 +1055,19 @@ fun ChatAttachAlert(
}, },
onItemClick = { item, position -> onItemClick = { item, position ->
if (!item.isVideo) { if (!item.isVideo) {
hideKeyboard()
if (onPhotoPreviewRequested != null) {
val photoItems = state.visibleMediaItems.filter { !it.isVideo }
val photoUris = photoItems.map { it.uri }
val currentIndex =
photoItems.indexOfFirst { it.id == item.id }
.takeIf { it >= 0 }
?: photoUris.indexOf(item.uri).coerceAtLeast(0)
onPhotoPreviewRequested(item.uri, position, photoUris, currentIndex)
} else {
thumbnailPosition = position thumbnailPosition = position
viewModel.setEditingItem(item) viewModel.setEditingItem(item)
}
} else { } else {
viewModel.toggleSelection(item.id, maxSelection) viewModel.toggleSelection(item.id, maxSelection)
} }
@@ -1089,6 +1179,9 @@ fun ChatAttachAlert(
if (!coordinator.isEmojiBoxVisible) { if (!coordinator.isEmojiBoxVisible) {
Spacer(modifier = Modifier.height(keyboardSpacerDp)) Spacer(modifier = Modifier.height(keyboardSpacerDp))
} }
if (navInsetDpForSheet > 0.dp) {
Spacer(modifier = Modifier.height(navInsetDpForSheet))
}
} // end Column } // end Column
// ── Floating Send Button ── // ── Floating Send Button ──
@@ -1112,7 +1205,7 @@ fun ChatAttachAlert(
}, },
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(bottom = bottomInputPadding) .padding(bottom = bottomInputPadding + navInsetDpForSheet)
) )
} // end Box sheet container } // end Box sheet container
@@ -1173,45 +1266,22 @@ fun ChatAttachAlert(
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
state.editingItem?.let { item -> state.editingItem?.let { item ->
ImageEditorScreen( val galleryPhotoItems = state.visibleMediaItems.filter { !it.isVideo }
val galleryPhotoUris = galleryPhotoItems.map { it.uri }
val initialGalleryIndex =
galleryPhotoItems.indexOfFirst { it.id == item.id }
.takeIf { it >= 0 }
?: galleryPhotoUris.indexOf(item.uri).coerceAtLeast(0)
SimpleFullscreenPhotoViewer(
imageUri = item.uri, imageUri = item.uri,
sourceThumbnail = thumbnailPosition,
galleryImageUris = galleryPhotoUris,
initialGalleryIndex = initialGalleryIndex,
onDismiss = { onDismiss = {
viewModel.setEditingItem(null) viewModel.setEditingItem(null)
thumbnailPosition = null thumbnailPosition = null
shouldShow = true shouldShow = true
},
onSave = { editedUri ->
viewModel.setEditingItem(null)
thumbnailPosition = null
if (onMediaSelectedWithCaption == null) {
previewPhotoUri = editedUri
} else {
val mediaItem = MediaItem(
id = System.currentTimeMillis(),
uri = editedUri,
mimeType = "image/png",
dateModified = System.currentTimeMillis()
)
onMediaSelected(listOf(mediaItem), "")
onDismiss()
} }
},
onSaveWithCaption = if (onMediaSelectedWithCaption != null) { editedUri, caption ->
viewModel.setEditingItem(null)
thumbnailPosition = null
val mediaItem = MediaItem(
id = System.currentTimeMillis(),
uri = editedUri,
mimeType = "image/png",
dateModified = System.currentTimeMillis()
)
onMediaSelectedWithCaption(mediaItem, caption)
onDismiss()
} else null,
isDarkTheme = isDarkTheme,
showCaptionInput = onMediaSelectedWithCaption != null,
recipientName = recipientName,
thumbnailPosition = thumbnailPosition
) )
} }

View File

@@ -2460,6 +2460,29 @@ private fun ForwardedImagePreview(
val cached = ImageBitmapCache.get(cacheKey) val cached = ImageBitmapCache.get(cacheKey)
if (cached != null) { imageBitmap = cached; return@LaunchedEffect } if (cached != null) { imageBitmap = cached; return@LaunchedEffect }
// 🚀 Optimistic forward: если есть localUri, показываем сразу локальный файл
if (attachment.localUri.isNotEmpty()) {
val localBitmap =
withContext(Dispatchers.IO) {
runCatching {
context.contentResolver
.openInputStream(
android.net.Uri.parse(
attachment.localUri
)
)
?.use { input ->
BitmapFactory.decodeStream(input)
}
}.getOrNull()
}
if (localBitmap != null) {
imageBitmap = localBitmap
ImageBitmapCache.put(cacheKey, localBitmap)
return@LaunchedEffect
}
}
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// Try local file cache first // Try local file cache first
try { try {

View File

@@ -829,7 +829,7 @@ fun ImageEditorScreen(
* Telegram-style toolbar - icons only, no labels * Telegram-style toolbar - icons only, no labels
*/ */
@Composable @Composable
private fun TelegramToolbar( internal fun TelegramToolbar(
currentTool: EditorTool, currentTool: EditorTool,
showCaptionInput: Boolean, showCaptionInput: Boolean,
isSaving: Boolean, isSaving: Boolean,
@@ -958,7 +958,7 @@ private fun TelegramToolButton(
* Telegram-style color picker with brush size * Telegram-style color picker with brush size
*/ */
@Composable @Composable
private fun TelegramColorPicker( internal fun TelegramColorPicker(
selectedColor: Color, selectedColor: Color,
brushSize: Float, brushSize: Float,
onColorSelected: (Color) -> Unit, onColorSelected: (Color) -> Unit,
@@ -1044,7 +1044,7 @@ private fun TelegramColorPicker(
* Telegram-style rotate bar * Telegram-style rotate bar
*/ */
@Composable @Composable
private fun TelegramRotateBar( internal fun TelegramRotateBar(
onRotateLeft: () -> Unit, onRotateLeft: () -> Unit,
onRotateRight: () -> Unit, onRotateRight: () -> Unit,
onFlipHorizontal: () -> Unit, onFlipHorizontal: () -> Unit,
@@ -1301,7 +1301,7 @@ private suspend fun saveEditedImageOld(
} }
/** Save edited image synchronously (with all editor changes). */ /** Save edited image synchronously (with all editor changes). */
private suspend fun saveEditedImageSync( internal suspend fun saveEditedImageSync(
context: Context, context: Context,
photoEditor: PhotoEditor?, photoEditor: PhotoEditor?,
photoEditorView: PhotoEditorView?, photoEditorView: PhotoEditorView?,
@@ -1489,7 +1489,7 @@ private fun getOrientedImageDimensions(context: Context, uri: Uri): Pair<Int, In
} }
/** Launch UCrop activity */ /** Launch UCrop activity */
private fun launchCrop( internal fun launchCrop(
context: Context, context: Context,
sourceUri: Uri, sourceUri: Uri,
launcher: androidx.activity.result.ActivityResultLauncher<Intent> launcher: androidx.activity.result.ActivityResultLauncher<Intent>

View File

@@ -69,6 +69,7 @@ import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.components.AppleEmojiTextField import com.rosetta.messenger.ui.components.AppleEmojiTextField
import com.rosetta.messenger.ui.components.KeyboardHeightProvider import com.rosetta.messenger.ui.components.KeyboardHeightProvider
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
import com.rosetta.messenger.ui.utils.NavigationModeUtils
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -82,6 +83,13 @@ import kotlin.math.roundToInt
private const val TAG = "MediaPickerBottomSheet" private const val TAG = "MediaPickerBottomSheet"
private const val ALL_MEDIA_ALBUM_ID = 0L private const val ALL_MEDIA_ALBUM_ID = 0L
private data class PickerSystemBarsSnapshot(
val scrimAlpha: Float,
val isFullScreen: Boolean,
val isDarkTheme: Boolean,
val openProgress: Float
)
/** /**
* Media item from gallery * Media item from gallery
*/ */
@@ -125,9 +133,13 @@ fun MediaPickerBottomSheet(
onAvatarClick: () -> Unit = {}, onAvatarClick: () -> Unit = {},
currentUserPublicKey: String = "", currentUserPublicKey: String = "",
maxSelection: Int = 10, maxSelection: Int = 10,
recipientName: String? = null recipientName: String? = null,
onPhotoPreviewRequested: ((Uri, ThumbnailPosition?, List<Uri>, Int) -> Unit)? = null
) { ) {
val context = LocalContext.current val context = LocalContext.current
val hasNativeNavigationBar = remember(context) {
NavigationModeUtils.hasNativeNavigationBar(context)
}
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val density = LocalDensity.current val density = LocalDensity.current
val imeInsets = WindowInsets.ime val imeInsets = WindowInsets.ime
@@ -287,7 +299,11 @@ fun MediaPickerBottomSheet(
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp val screenHeight = configuration.screenHeightDp.dp
val screenHeightPx = with(density) { screenHeight.toPx() } val screenHeightPx = with(density) { screenHeight.toPx() }
val navigationBarInsetPx = WindowInsets.navigationBars.getBottom(density).toFloat() val navigationBarInsetPx = if (hasNativeNavigationBar) {
WindowInsets.navigationBars.getBottom(density).toFloat()
} else {
0f
}
val statusBarInsetPx = WindowInsets.statusBars.getTop(density).toFloat() val statusBarInsetPx = WindowInsets.statusBars.getTop(density).toFloat()
// 🔄 Высоты в пикселях для точного контроля // 🔄 Высоты в пикселях для точного контроля
@@ -376,6 +392,9 @@ fun MediaPickerBottomSheet(
// Запускаем анимацию когда showSheet меняется // Запускаем анимацию когда showSheet меняется
LaunchedEffect(showSheet) { LaunchedEffect(showSheet) {
if (showSheet && editingItem == null) { if (showSheet && editingItem == null) {
// Ensure IME from chat is closed before picker opens.
hideKeyboard()
delay(16)
shouldShow = true shouldShow = true
isClosing = false isClosing = false
showAlbumMenu = false showAlbumMenu = false
@@ -554,12 +573,21 @@ fun MediaPickerBottomSheet(
val origNavBarColor = window?.navigationBarColor ?: 0 val origNavBarColor = window?.navigationBarColor ?: 0
val origLightNav = insetsController?.isAppearanceLightNavigationBars ?: true val origLightNav = insetsController?.isAppearanceLightNavigationBars ?: true
val origLightStatus = insetsController?.isAppearanceLightStatusBars ?: false val origLightStatus = insetsController?.isAppearanceLightStatusBars ?: false
val origContrastEnforced =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window?.isNavigationBarContrastEnforced
} else {
null
}
onDispose { onDispose {
window?.statusBarColor = origStatusBarColor window?.statusBarColor = origStatusBarColor
window?.navigationBarColor = origNavBarColor window?.navigationBarColor = origNavBarColor
insetsController?.isAppearanceLightNavigationBars = origLightNav insetsController?.isAppearanceLightNavigationBars = origLightNav
insetsController?.isAppearanceLightStatusBars = origLightStatus insetsController?.isAppearanceLightStatusBars = origLightStatus
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && origContrastEnforced != null) {
window?.isNavigationBarContrastEnforced = origContrastEnforced
}
} }
} else { } else {
onDispose { } onDispose { }
@@ -567,12 +595,21 @@ fun MediaPickerBottomSheet(
} }
// Reactive updates — single snapshotFlow drives ALL system bar colors // Reactive updates — single snapshotFlow drives ALL system bar colors
LaunchedEffect(shouldShow) { LaunchedEffect(shouldShow, editingItem) {
if (!shouldShow) return@LaunchedEffect if (!shouldShow || editingItem != null) return@LaunchedEffect
val window = (view.context as? android.app.Activity)?.window ?: return@LaunchedEffect val window = (view.context as? android.app.Activity)?.window ?: return@LaunchedEffect
snapshotFlow { Triple(scrimAlpha, isPickerFullScreen, isDarkTheme) } snapshotFlow {
.collect { (alpha, fullScreen, dark) -> PickerSystemBarsSnapshot(
scrimAlpha = scrimAlpha,
isFullScreen = isPickerFullScreen,
isDarkTheme = isDarkTheme,
openProgress = (1f - animatedOffset).coerceIn(0f, 1f)
)
}.collect { state ->
val alpha = state.scrimAlpha
val fullScreen = state.isFullScreen
val dark = state.isDarkTheme
if (fullScreen) { if (fullScreen) {
// Full screen: status bar = picker background, seamless // Full screen: status bar = picker background, seamless
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt() window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
@@ -584,16 +621,33 @@ fun MediaPickerBottomSheet(
) )
insetsController?.isAppearanceLightStatusBars = false insetsController?.isAppearanceLightStatusBars = false
} }
// Navigation bar always follows scrim if (hasNativeNavigationBar) {
// Telegram-like: for 2/3-button navigation keep picker-surface tint.
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
window.navigationBarColor = android.graphics.Color.argb( window.navigationBarColor = android.graphics.Color.argb(
(alpha * 255).toInt().coerceIn(0, 255), 0, 0, 0 navAlpha,
android.graphics.Color.red(navBaseColor),
android.graphics.Color.green(navBaseColor),
android.graphics.Color.blue(navBaseColor)
) )
insetsController?.isAppearanceLightNavigationBars = alpha < 0.15f if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = true
}
insetsController?.isAppearanceLightNavigationBars = !dark
} else {
// Telegram-like on gesture navigation: transparent stable nav area.
window.navigationBarColor = android.graphics.Color.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
insetsController?.isAppearanceLightNavigationBars = !dark
}
} }
} }
// Используем Popup для показа поверх клавиатуры // Используем Popup для показа поверх клавиатуры
if (shouldShow) { if (shouldShow && editingItem == null) {
// BackHandler для закрытия по back // BackHandler для закрытия по back
BackHandler { BackHandler {
if (isExpanded) { if (isExpanded) {
@@ -627,7 +681,8 @@ fun MediaPickerBottomSheet(
(imeBottomInsetPx.toFloat() - navigationBarInsetPx).coerceAtLeast(0f) (imeBottomInsetPx.toFloat() - navigationBarInsetPx).coerceAtLeast(0f)
val appliedKeyboardInsetPx = val appliedKeyboardInsetPx =
if (selectedItemOrder.isNotEmpty()) keyboardInsetPx else 0f if (selectedItemOrder.isNotEmpty()) keyboardInsetPx else 0f
val navBarDp = with(density) { navigationBarInsetPx.toDp() } val navInsetPxForSheet = if (appliedKeyboardInsetPx > 0f) 0f else navigationBarInsetPx
val navInsetDpForSheet = with(density) { navInsetPxForSheet.toDp() }
// Полноэкранный контейнер с мягким затемнением // Полноэкранный контейнер с мягким затемнением
// background BEFORE padding — scrim covers area behind keyboard too // background BEFORE padding — scrim covers area behind keyboard too
@@ -638,14 +693,14 @@ fun MediaPickerBottomSheet(
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null indication = null
) { requestClose() } ) { requestClose() },
.padding(bottom = navBarDp),
contentAlignment = Alignment.BottomCenter contentAlignment = Alignment.BottomCenter
) { ) {
// Subtract keyboard from sheet height so it fits in the resized viewport. // Subtract keyboard from sheet height so it fits in the resized viewport.
// The grid (weight=1f) shrinks; caption bar stays at the bottom edge. // The grid (weight=1f) shrinks; caption bar stays at the bottom edge.
val visibleSheetHeightPx = val visibleSheetHeightPx =
(sheetHeightPx.value - appliedKeyboardInsetPx).coerceAtLeast(minHeightPx) (sheetHeightPx.value - appliedKeyboardInsetPx + navInsetPxForSheet)
.coerceAtLeast(minHeightPx)
val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() } val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() }
val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt() val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt()
val expandProgress = val expandProgress =
@@ -986,8 +1041,19 @@ fun MediaPickerBottomSheet(
}, },
onItemClick = { item, position -> onItemClick = { item, position ->
if (!item.isVideo) { if (!item.isVideo) {
hideKeyboard()
if (onPhotoPreviewRequested != null) {
val photoItems = visibleMediaItems.filter { !it.isVideo }
val photoUris = photoItems.map { it.uri }
val currentIndex =
photoItems.indexOfFirst { it.id == item.id }
.takeIf { it >= 0 }
?: photoUris.indexOf(item.uri).coerceAtLeast(0)
onPhotoPreviewRequested(item.uri, position, photoUris, currentIndex)
} else {
thumbnailPosition = position thumbnailPosition = position
editingItem = item editingItem = item
}
} else { } else {
// Videos don't have photo editor in this flow. // Videos don't have photo editor in this flow.
toggleSelection(item.id) toggleSelection(item.id)
@@ -1145,6 +1211,9 @@ fun MediaPickerBottomSheet(
} }
} }
} }
if (navInsetDpForSheet > 0.dp) {
Spacer(modifier = Modifier.height(navInsetDpForSheet))
}
} // end Column } // end Column
// ═══════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════
@@ -1163,7 +1232,7 @@ fun MediaPickerBottomSheet(
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(end = 14.dp, bottom = 8.dp) .padding(end = 14.dp, bottom = 8.dp + navInsetDpForSheet)
.graphicsLayer { .graphicsLayer {
scaleX = sendScale scaleX = sendScale
scaleY = sendScale scaleY = sendScale
@@ -1279,48 +1348,16 @@ fun MediaPickerBottomSheet(
) )
} }
// Image Editor FULLSCREEN overlay для фото из галереи // Fullscreen preview для выбранной фото из галереи (чистый экран без тулбаров).
// ImageEditorScreen wraps itself in a Dialog internally — no external wrapper needed
editingItem?.let { item -> editingItem?.let { item ->
ImageEditorScreen( SimpleFullscreenPhotoViewer(
imageUri = item.uri, imageUri = item.uri,
sourceThumbnail = thumbnailPosition,
onDismiss = { onDismiss = {
editingItem = null editingItem = null
thumbnailPosition = null thumbnailPosition = null
shouldShow = true shouldShow = true
},
onSave = { editedUri ->
editingItem = null
thumbnailPosition = null
if (onMediaSelectedWithCaption == null) {
previewPhotoUri = editedUri
} else {
val mediaItem = MediaItem(
id = System.currentTimeMillis(),
uri = editedUri,
mimeType = "image/png",
dateModified = System.currentTimeMillis()
)
onMediaSelected(listOf(mediaItem), "")
onDismiss()
} }
},
onSaveWithCaption = if (onMediaSelectedWithCaption != null) { editedUri, caption ->
editingItem = null
thumbnailPosition = null
val mediaItem = MediaItem(
id = System.currentTimeMillis(),
uri = editedUri,
mimeType = "image/png",
dateModified = System.currentTimeMillis()
)
onMediaSelectedWithCaption(mediaItem, caption)
onDismiss()
} else null,
isDarkTheme = isDarkTheme,
showCaptionInput = onMediaSelectedWithCaption != null,
recipientName = recipientName,
thumbnailPosition = thumbnailPosition
) )
} }
@@ -2208,6 +2245,9 @@ fun PhotoPreviewWithCaptionScreen(
val backgroundColor = if (isDarkTheme) Color.Black else Color.White val backgroundColor = if (isDarkTheme) Color.Black else Color.White
val context = LocalContext.current val context = LocalContext.current
val hasNativeNavigationBar = remember(context) {
NavigationModeUtils.hasNativeNavigationBar(context)
}
val view = LocalView.current val view = LocalView.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val density = LocalDensity.current val density = LocalDensity.current
@@ -2292,7 +2332,8 @@ fun PhotoPreviewWithCaptionScreen(
// 🔥 КЛЮЧЕВОЕ: imePadding применяется ТОЛЬКО когда emoji box НЕ виден // 🔥 КЛЮЧЕВОЕ: imePadding применяется ТОЛЬКО когда emoji box НЕ виден
val shouldUseImePadding = !coordinator.isEmojiBoxVisible val shouldUseImePadding = !coordinator.isEmojiBoxVisible
val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible val shouldAddNavBarPadding =
hasNativeNavigationBar && !isKeyboardVisible && !coordinator.isEmojiBoxVisible
// Логируем состояние при каждой рекомпозиции // Логируем состояние при каждой рекомпозиции
Surface( Surface(

View File

@@ -36,6 +36,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
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.Popup
import androidx.compose.ui.window.PopupProperties
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
import coil.compose.AsyncImage import coil.compose.AsyncImage
@@ -88,6 +90,7 @@ fun MessageInputBar(
replyMessages: List<ChatViewModel.ReplyMessage> = emptyList(), replyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
isForwardMode: Boolean = false, isForwardMode: Boolean = false,
onCloseReply: () -> Unit = {}, onCloseReply: () -> Unit = {},
onShowForwardOptions: (List<ChatViewModel.ReplyMessage>) -> Unit = {},
chatTitle: String = "", chatTitle: String = "",
isBlocked: Boolean = false, isBlocked: Boolean = false,
showEmojiPicker: Boolean = false, showEmojiPicker: Boolean = false,
@@ -104,7 +107,8 @@ fun MessageInputBar(
mentionCandidates: List<MentionCandidate> = emptyList(), mentionCandidates: List<MentionCandidate> = emptyList(),
avatarRepository: AvatarRepository? = null, avatarRepository: AvatarRepository? = null,
inputFocusTrigger: Int = 0, inputFocusTrigger: Int = 0,
suppressKeyboard: Boolean = false suppressKeyboard: Boolean = false,
hasNativeNavigationBar: Boolean = true
) { ) {
val hasReply = replyMessages.isNotEmpty() val hasReply = replyMessages.isNotEmpty()
val liveReplyMessages = val liveReplyMessages =
@@ -217,6 +221,11 @@ fun MessageInputBar(
val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply } val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply }
var isSending by remember { mutableStateOf(false) } var isSending by remember { mutableStateOf(false) }
var showForwardCancelDialog by remember { mutableStateOf(false) }
var forwardCancelDialogCount by remember { mutableIntStateOf(0) }
var forwardCancelDialogMessages by remember {
mutableStateOf<List<ChatViewModel.ReplyMessage>>(emptyList())
}
val mentionPattern = remember { Regex("@([\\w\\d_]*)$") } val mentionPattern = remember { Regex("@([\\w\\d_]*)$") }
val mentionedPattern = remember { Regex("@([\\w\\d_]{1,})") } val mentionedPattern = remember { Regex("@([\\w\\d_]{1,})") }
val mentionMatch = remember(value, isGroupChat) { if (isGroupChat) mentionPattern.find(value) else null } val mentionMatch = remember(value, isGroupChat) { if (isGroupChat) mentionPattern.find(value) else null }
@@ -367,7 +376,10 @@ fun MessageInputBar(
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 16.dp) .padding(horizontal = 12.dp, vertical = 16.dp)
.padding(bottom = 20.dp) .padding(bottom = 20.dp)
.navigationBarsPadding(), .then(
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
else Modifier
),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
@@ -398,7 +410,10 @@ fun MessageInputBar(
) )
) )
val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible val shouldAddNavBarPadding =
hasNativeNavigationBar &&
!isKeyboardVisible &&
!coordinator.isEmojiBoxVisible
Column( Column(
modifier = Modifier modifier = Modifier
@@ -654,7 +669,18 @@ fun MessageInputBar(
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, indication = null,
onClick = onCloseReply onClick = {
if (panelIsForwardMode && panelReplyMessages.isNotEmpty()) {
val sourceMessages =
if (replyMessages.isNotEmpty()) replyMessages
else panelReplyMessages
forwardCancelDialogCount = sourceMessages.size
forwardCancelDialogMessages = sourceMessages
showForwardCancelDialog = true
} else {
onCloseReply()
}
}
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@@ -758,6 +784,122 @@ fun MessageInputBar(
} }
} }
if (showForwardCancelDialog) {
val titleText =
if (forwardCancelDialogCount == 1) "1 message"
else "$forwardCancelDialogCount messages"
val bodyText =
"What would you like to do with $titleText from your chat with ${chatTitle.ifBlank { "this user" }}?"
val popupInteraction = remember { MutableInteractionSource() }
val cardInteraction = remember { MutableInteractionSource() }
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE5E5EA)
val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
val bodyColor = if (isDarkTheme) Color(0xFFD1D1D6) else Color(0xFF3C3C43)
Popup(
alignment = Alignment.Center,
onDismissRequest = {
showForwardCancelDialog = false
forwardCancelDialogMessages = emptyList()
},
properties =
PopupProperties(
focusable = false,
dismissOnBackPress = true,
dismissOnClickOutside = true,
clippingEnabled = false
)
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.36f))
.clickable(
interactionSource = popupInteraction,
indication = null
) { showForwardCancelDialog = false },
contentAlignment = Alignment.Center
) {
Surface(
modifier =
Modifier
.fillMaxWidth(0.88f)
.widthIn(max = 360.dp)
.clip(RoundedCornerShape(16.dp))
.clickable(
interactionSource = cardInteraction,
indication = null
) {},
color = cardColor,
shadowElevation = 18.dp
) {
Column(modifier = Modifier.fillMaxWidth()) {
Spacer(modifier = Modifier.height(18.dp))
Text(
text = titleText,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
color = if (isDarkTheme) Color.White else Color.Black,
modifier = Modifier.padding(horizontal = 20.dp)
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = bodyText,
fontSize = 16.sp,
lineHeight = 21.sp,
color = bodyColor,
modifier = Modifier.padding(horizontal = 20.dp)
)
Spacer(modifier = Modifier.height(14.dp))
Divider(thickness = 0.5.dp, color = dividerColor)
TextButton(
onClick = {
showForwardCancelDialog = false
val panelMessages =
if (replyMessages.isNotEmpty()) {
replyMessages
} else if (forwardCancelDialogMessages.isNotEmpty()) {
forwardCancelDialogMessages
} else if (liveReplyMessages.isNotEmpty()) {
liveReplyMessages
} else {
animatedReplyMessages
}
onShowForwardOptions(panelMessages)
forwardCancelDialogMessages = emptyList()
},
modifier = Modifier.fillMaxWidth().height(52.dp)
) {
Text(
text = "Show forwarding options",
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = PrimaryBlue
)
}
TextButton(
onClick = {
showForwardCancelDialog = false
forwardCancelDialogMessages = emptyList()
onCloseReply()
},
modifier = Modifier.fillMaxWidth().height(52.dp)
) {
Text(
text = "Cancel Forwarding",
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = Color(0xFFFF5D73)
)
}
Spacer(modifier = Modifier.height(6.dp))
}
}
}
}
}
// EMOJI PICKER // EMOJI PICKER
if (!isBlocked) { if (!isBlocked) {
AnimatedKeyboardTransition( AnimatedKeyboardTransition(

View File

@@ -45,8 +45,6 @@ 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.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.airbnb.lottie.compose.* import com.airbnb.lottie.compose.*
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.ui.theme.* import com.rosetta.messenger.ui.theme.*
@@ -124,8 +122,11 @@ fun OnboardingScreen(
// Animate navigation bar color starting at 80% of wave animation // Animate navigation bar color starting at 80% of wave animation
val view = LocalView.current val view = LocalView.current
val isGestureNavigation = remember(view.context) {
NavigationModeUtils.isGestureNavigation(view.context)
}
LaunchedEffect(isTransitioning, transitionProgress) { LaunchedEffect(isTransitioning, transitionProgress) {
if (isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) { if (!isGestureNavigation && isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
// Map 0.8-1.0 to 0-1 for smooth interpolation // Map 0.8-1.0 to 0-1 for smooth interpolation
val navProgress = ((transitionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f) val navProgress = ((transitionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f)
@@ -163,7 +164,10 @@ fun OnboardingScreen(
// Navigation bar: показываем только если есть нативные кнопки // Navigation bar: показываем только если есть нативные кнопки
NavigationModeUtils.applyNavigationBarVisibility( NavigationModeUtils.applyNavigationBarVisibility(
insetsController, view.context, isDarkTheme window = window,
insetsController = insetsController,
context = view.context,
isDarkTheme = isDarkTheme
) )
} }
} }
@@ -173,10 +177,12 @@ fun OnboardingScreen(
if (!view.isInEditMode) { if (!view.isInEditMode) {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = WindowCompat.getInsetsController(window, view) val insetsController = WindowCompat.getInsetsController(window, view)
window.navigationBarColor = NavigationModeUtils.applyNavigationBarVisibility(
if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt() window = window,
insetsController.show(WindowInsetsCompat.Type.navigationBars()) insetsController = insetsController,
insetsController.isAppearanceLightNavigationBars = !isDarkTheme context = view.context,
isDarkTheme = isDarkTheme
)
} }
} }

View File

@@ -1897,6 +1897,9 @@ private fun CollapsingOtherProfileHeader(
val isRosettaOfficial = val isRosettaOfficial =
name.equals("Rosetta", ignoreCase = true) || name.equals("Rosetta", ignoreCase = true) ||
username.equals("rosetta", ignoreCase = true) username.equals("rosetta", ignoreCase = true)
val isFreddyOfficial =
name.equals("freddy", ignoreCase = true) ||
username.equals("freddy", ignoreCase = true)
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой // 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой
@@ -2151,7 +2154,7 @@ private fun CollapsingOtherProfileHeader(
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
if (verified > 0 || isRosettaOfficial) { if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge( VerifiedBadge(
verified = if (verified > 0) verified else 1, verified = if (verified > 0) verified else 1,

View File

@@ -1136,6 +1136,9 @@ private fun CollapsingProfileHeader(
val isRosettaOfficial = val isRosettaOfficial =
name.equals("Rosetta", ignoreCase = true) || name.equals("Rosetta", ignoreCase = true) ||
username.equals("rosetta", ignoreCase = true) username.equals("rosetta", ignoreCase = true)
val isFreddyOfficial =
name.equals("freddy", ignoreCase = true) ||
username.equals("freddy", ignoreCase = true)
Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) { Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
// Expansion fraction — computed early so gradient can fade during expansion // Expansion fraction — computed early so gradient can fade during expansion
@@ -1400,7 +1403,7 @@ private fun CollapsingProfileHeader(
modifier = Modifier.widthIn(max = 220.dp), modifier = Modifier.widthIn(max = 220.dp),
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
if (verified > 0 || isRosettaOfficial) { if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge( VerifiedBadge(
verified = if (verified > 0) verified else 2, verified = if (verified > 0) verified else 2,

View File

@@ -68,15 +68,21 @@ fun RosettaAndroidTheme(
val view = LocalView.current val view = LocalView.current
val context = LocalContext.current val context = LocalContext.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { DisposableEffect(darkTheme, view, context) {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = WindowCompat.getInsetsController(window, view) val insetsController = WindowCompat.getInsetsController(window, view)
// Make status bar transparent for wave animation overlay // Make status bar transparent for wave animation overlay
window.statusBarColor = AndroidColor.TRANSPARENT window.statusBarColor = AndroidColor.TRANSPARENT
// Note: isAppearanceLightStatusBars is managed per-screen, not globally // Note: isAppearanceLightStatusBars is managed per-screen, not globally
// Navigation bar: показываем только если есть нативные кнопки NavigationModeUtils.applyNavigationBarVisibility(
NavigationModeUtils.applyNavigationBarVisibility(insetsController, context, darkTheme) window = window,
insetsController = insetsController,
context = context,
isDarkTheme = darkTheme
)
onDispose { }
} }
} }

View File

@@ -1,6 +1,10 @@
package com.rosetta.messenger.ui.utils package com.rosetta.messenger.ui.utils
import android.content.Context import android.content.Context
import android.graphics.Color
import android.os.Build
import android.view.View
import android.view.Window
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -58,12 +62,42 @@ object NavigationModeUtils {
* Показывает navigation bar на всех устройствах. * Показывает navigation bar на всех устройствах.
*/ */
fun applyNavigationBarVisibility( fun applyNavigationBarVisibility(
window: Window,
insetsController: WindowInsetsControllerCompat, insetsController: WindowInsetsControllerCompat,
context: Context, context: Context,
isDarkTheme: Boolean isDarkTheme: Boolean
) { ) {
val gestureNavigation = isGestureNavigation(context)
val decorView = window.decorView
insetsController.show(WindowInsetsCompat.Type.navigationBars()) insetsController.show(WindowInsetsCompat.Type.navigationBars())
if (gestureNavigation) {
// In gesture mode we keep a transparent nav bar and fixed white handle.
// This avoids the "jump" effect during theme switching.
window.navigationBarColor = Color.TRANSPARENT
insetsController.isAppearanceLightNavigationBars = false
val newFlags =
decorView.systemUiVisibility or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
if (newFlags != decorView.systemUiVisibility) {
decorView.systemUiVisibility = newFlags
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
} else {
window.navigationBarColor = if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt()
insetsController.isAppearanceLightNavigationBars = !isDarkTheme insetsController.isAppearanceLightNavigationBars = !isDarkTheme
val newFlags =
decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION.inv()
if (newFlags != decorView.systemUiVisibility) {
decorView.systemUiVisibility = newFlags
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = true
}
}
} }
} }