feat: enhance image viewer navigation with tap animations and controls visibility

This commit is contained in:
2026-02-09 21:14:10 +05:00
parent efdb93d136
commit 3c37a3b0e5
12 changed files with 1620 additions and 376 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -841,14 +841,14 @@ fun MainScreen(
) )
} }
// 🖼️ Image viewer state — disable swipe-back when photo is fullscreen // 🔒 Lock swipe-back while chat overlays are open (image viewer/editor/media picker/camera).
var isImageViewerOpen by remember { mutableStateOf(false) } var isChatSwipeLocked by remember { mutableStateOf(false) }
SwipeBackContainer( SwipeBackContainer(
isVisible = selectedUser != null, isVisible = selectedUser != null,
onBack = { popChatAndChildren() }, onBack = { popChatAndChildren() },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
swipeEnabled = !isImageViewerOpen swipeEnabled = !isChatSwipeLocked
) { ) {
selectedUser?.let { currentChatUser -> selectedUser?.let { currentChatUser ->
// Экран чата // Экран чата
@@ -870,7 +870,7 @@ fun MainScreen(
}, },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
onImageViewerChanged = { isOpen -> isImageViewerOpen = isOpen } onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked }
) )
} }
} }
@@ -928,7 +928,9 @@ fun MainScreen(
user = currentOtherUser, user = currentOtherUser,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } }, onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } },
avatarRepository = avatarRepository avatarRepository = avatarRepository,
currentUserPublicKey = accountPublicKey,
currentUserPrivateKey = accountPrivateKey
) )
} }
} }

View File

@@ -10,6 +10,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by private val Context.dataStore: DataStore<Preferences> by
@@ -49,6 +50,9 @@ class PreferencesManager(private val context: Context) {
// Pinned Chats (max 3) // Pinned Chats (max 3)
val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys
// Muted Chats (stored as "account::opponentKey")
val MUTED_CHATS = stringSetPreferencesKey("muted_chats")
} }
// ═════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════
@@ -238,4 +242,63 @@ class PreferencesManager(private val context: Context) {
} }
return wasPinned return wasPinned
} }
// ═════════════════════════════════════════════════════════════
// 🔕 MUTED CHATS
// ═════════════════════════════════════════════════════════════
private fun buildMutedKey(account: String, opponentKey: String): String {
val trimmedAccount = account.trim()
val trimmedOpponent = opponentKey.trim()
return if (trimmedAccount.isBlank()) trimmedOpponent else "$trimmedAccount::$trimmedOpponent"
}
private fun parseMutedKeyForAccount(rawKey: String, account: String): String? {
val trimmedAccount = account.trim()
if (rawKey.isBlank()) return null
// Legacy format support: plain opponentKey without account prefix.
if ("::" !in rawKey) return rawKey
val parts = rawKey.split("::", limit = 2)
if (parts.size != 2) return null
val keyAccount = parts[0]
val opponentKey = parts[1]
return if (trimmedAccount.isBlank() || keyAccount == trimmedAccount) opponentKey else null
}
val mutedChats: Flow<Set<String>> =
context.dataStore.data.map { preferences -> preferences[MUTED_CHATS] ?: emptySet() }
fun mutedChatsForAccount(account: String): Flow<Set<String>> =
mutedChats.map { muted ->
muted.mapNotNull { parseMutedKeyForAccount(it, account) }.toSet()
}
suspend fun isChatMuted(account: String, opponentKey: String): Boolean {
if (opponentKey.isBlank()) return false
val current = mutedChats.first()
return current.contains(buildMutedKey(account, opponentKey)) || current.contains(opponentKey)
}
suspend fun setChatMuted(account: String, opponentKey: String, muted: Boolean) {
if (opponentKey.isBlank()) return
val scopedKey = buildMutedKey(account, opponentKey)
context.dataStore.edit { preferences ->
val current = preferences[MUTED_CHATS] ?: emptySet()
preferences[MUTED_CHATS] =
if (muted) {
current + scopedKey
} else {
current - scopedKey - opponentKey
}
}
}
suspend fun toggleMuteChat(account: String, opponentKey: String): Boolean {
if (opponentKey.isBlank()) return false
val mutedNow = !isChatMuted(account, opponentKey)
setChatMuted(account, opponentKey, mutedNow)
return mutedNow
}
} }

View File

@@ -11,9 +11,12 @@ import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import com.rosetta.messenger.MainActivity import com.rosetta.messenger.MainActivity
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.PreferencesManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
/** /**
* Firebase Cloud Messaging Service для обработки push-уведомлений * Firebase Cloud Messaging Service для обработки push-уведомлений
@@ -87,6 +90,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
if (isAppInForeground) { if (isAppInForeground) {
return return
} }
val senderKey = senderPublicKey?.trim().orEmpty()
if (senderKey.isNotEmpty() && isDialogMuted(senderKey)) {
return
}
createNotificationChannel() createNotificationChannel()
@@ -183,4 +190,16 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val prefs = getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE) val prefs = getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE)
prefs.edit().putString("fcm_token", token).apply() prefs.edit().putString("fcm_token", token).apply()
} }
/** Проверка: замьючен ли диалог для текущего аккаунта */
private fun isDialogMuted(senderPublicKey: String): Boolean {
if (senderPublicKey.isBlank()) return false
return runCatching {
val accountManager = AccountManager(applicationContext)
val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty()
runBlocking {
PreferencesManager(applicationContext).isChatMuted(currentAccount, senderPublicKey)
}
}.getOrDefault(false)
}
} }

View File

@@ -222,6 +222,34 @@ fun ChatDetailScreen(
// Триггер для возврата фокуса на инпут после отправки фото из редактора // Триггер для возврата фокуса на инпут после отправки фото из редактора
var inputFocusTrigger by remember { mutableStateOf(0) } var inputFocusTrigger by remember { mutableStateOf(0) }
// Блокируем swipe-back родительского экрана, пока открыт fullscreen/media overlay.
val shouldLockParentSwipeBack by
remember(
showImageViewer,
showMediaPicker,
showEmojiPicker,
pendingCameraPhotoUri,
pendingGalleryImages,
showInAppCamera
) {
derivedStateOf {
showImageViewer ||
showMediaPicker ||
showEmojiPicker ||
pendingCameraPhotoUri != null ||
pendingGalleryImages.isNotEmpty() ||
showInAppCamera
}
}
LaunchedEffect(shouldLockParentSwipeBack) {
onImageViewerChanged(shouldLockParentSwipeBack)
}
DisposableEffect(Unit) {
onDispose { onImageViewerChanged(false) }
}
// <20>📷 Camera launcher // <20>📷 Camera launcher
val cameraLauncher = val cameraLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(

View File

@@ -1420,11 +1420,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** 🔥 Очистить reply/forward */ /** 🔥 Очистить reply/forward */
fun clearReplyMessages() { fun clearReplyMessages() {
viewModelScope.launch { _replyMessages.value = emptyList()
delay(350) // Задержка после закрытия панели (анимация fadeOut + shrinkVertically) _isForwardMode.value = false
_replyMessages.value = emptyList()
_isForwardMode.value = false
}
} }
/** 🔥 Удалить сообщение (для ошибки отправки) */ /** 🔥 Удалить сообщение (для ошибки отправки) */
@@ -1793,6 +1790,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
attachmentsJson = optimisticAttachmentsJson, attachmentsJson = optimisticAttachmentsJson,
opponentPublicKey = recipient opponentPublicKey = recipient
) )
// Обновляем dialogs сразу, чтобы в списке чатов мгновенно показать "Photo" + часы
// даже если пользователь вышел из экрана чата во время загрузки.
saveDialog(
lastMessage = if (text.isNotEmpty()) text else "photo",
timestamp = timestamp,
opponentPublicKey = recipient
)
} catch (_: Exception) { } catch (_: Exception) {
} }

View File

@@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
@@ -54,6 +55,7 @@ import androidx.compose.ui.text.font.FontWeight
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.exifinterface.media.ExifInterface
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.components.AppleEmojiTextField import com.rosetta.messenger.ui.components.AppleEmojiTextField
@@ -159,6 +161,10 @@ fun ImageEditorScreen(
// Запуск enter анимации (пропускаем если из камеры — уже alpha=1) // Запуск enter анимации (пропускаем если из камеры — уже alpha=1)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
if (!skipEnterAnimation) { if (!skipEnterAnimation) {
animationProgress.animateTo( animationProgress.animateTo(
targetValue = 1f, targetValue = 1f,
@@ -314,12 +320,25 @@ fun ImageEditorScreen(
result.data?.let { data -> result.data?.let { data ->
UCrop.getOutput(data)?.let { croppedUri -> UCrop.getOutput(data)?.let { croppedUri ->
currentImageUri = croppedUri currentImageUri = croppedUri
photoEditorView?.source?.setImageURI(croppedUri)
} }
} }
} }
} }
// Загружаем изображение с корректным EXIF, чтобы в редакторе не было переворота/зеркала.
LaunchedEffect(currentImageUri, photoEditorView) {
val targetView = photoEditorView ?: return@LaunchedEffect
val orientedBitmap = withContext(Dispatchers.IO) {
loadBitmapRespectExif(context, currentImageUri)
}
orientedBitmap?.let { bitmap ->
targetView.source.setImageBitmap(bitmap)
targetView.source.scaleType = ImageView.ScaleType.FIT_CENTER
targetView.source.adjustViewBounds = true
targetView.source.setPadding(0, 0, 0, 0)
}
}
BackHandler { animatedDismiss() } BackHandler { animatedDismiss() }
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -396,7 +415,6 @@ fun ImageEditorScreen(
// Простой FIT_CENTER - показывает ВСЁ фото, центрирует // Простой FIT_CENTER - показывает ВСЁ фото, центрирует
source.apply { source.apply {
setImageURI(currentImageUri)
scaleType = ImageView.ScaleType.FIT_CENTER scaleType = ImageView.ScaleType.FIT_CENTER
adjustViewBounds = true adjustViewBounds = true
setPadding(0, 0, 0, 0) setPadding(0, 0, 0, 0)
@@ -605,27 +623,35 @@ fun ImageEditorScreen(
onToggleEmojiPicker = { toggleEmojiPicker() }, onToggleEmojiPicker = { toggleEmojiPicker() },
onEditTextViewCreated = { editTextView = it }, onEditTextViewCreated = { editTextView = it },
onSend = { onSend = {
// 🚀 Сохраняем caption для использования после анимации if (isSaving || isClosing) return@TelegramCaptionBar
val captionToSend = caption val captionToSend = caption
val uriToSend = currentImageUri val uriToSend = currentImageUri
// ✈️ Сразу запускаем fade-out анимацию (как в Telegram)
// Фото появится в чате через optimistic UI
scope.launch { scope.launch {
saveEditedImage(context, photoEditor, photoEditorView, uriToSend) { savedUri -> isSaving = true
if (savedUri != null) {
// Вызываем callback (он запустит sendImageMessage с optimistic UI)
if (onSaveWithCaption != null) {
onSaveWithCaption(savedUri, captionToSend)
} else {
onSave(savedUri)
}
}
}
}
// 🎬 Плавно закрываем экран (fade-out) val savedUri =
animatedDismiss() saveEditedImageSync(
context = context,
photoEditor = photoEditor,
photoEditorView = photoEditorView,
imageUri = uriToSend
)
isSaving = false
val finalUri = savedUri ?: uriToSend
// Сначала отправляем, затем закрываем экран
if (onSaveWithCaption != null) {
onSaveWithCaption(finalUri, captionToSend)
} else {
onSave(finalUri)
}
animatedDismiss()
}
} }
) )
@@ -735,12 +761,15 @@ fun ImageEditorScreen(
if (!showCaptionInput) { if (!showCaptionInput) {
scope.launch { scope.launch {
isSaving = true isSaving = true
saveEditedImage(context, photoEditor, photoEditorView, currentImageUri) { savedUri -> val savedUri =
isSaving = false saveEditedImageSync(
if (savedUri != null) { context = context,
onSave(savedUri) photoEditor = photoEditor,
} photoEditorView = photoEditorView,
} imageUri = currentImageUri
)
isSaving = false
onSave(savedUri ?: currentImageUri)
} }
} }
} }
@@ -1155,7 +1184,7 @@ private fun TelegramCaptionBar(
} }
} }
/** Save edited image and return the URI - returns ORIGINAL without processing */ /** Save edited image and return the URI (with all editor changes). */
private suspend fun saveEditedImage( private suspend fun saveEditedImage(
context: Context, context: Context,
photoEditor: PhotoEditor?, photoEditor: PhotoEditor?,
@@ -1163,9 +1192,13 @@ private suspend fun saveEditedImage(
imageUri: Uri, imageUri: Uri,
onResult: (Uri?) -> Unit onResult: (Uri?) -> Unit
) { ) {
// Просто возвращаем оригинальный URI без обработки через PhotoEditor saveEditedImageOld(
// Это устраняет проблему с обрезкой изображений context = context,
onResult(imageUri) photoEditor = photoEditor,
photoEditorView = photoEditorView,
imageUri = imageUri,
onResult = onResult
)
} }
/** OLD VERSION - Save edited image with crop logic (disabled due to cropping issues) */ /** OLD VERSION - Save edited image with crop logic (disabled due to cropping issues) */
@@ -1183,126 +1216,50 @@ private suspend fun saveEditedImageOld(
return return
} }
withContext(Dispatchers.IO) { try {
try { val tempFile = File(context.cacheDir, "edited_${System.currentTimeMillis()}.png")
// Загружаем оригинальное изображение и сохраняем его напрямую val saveSettings = SaveSettings.Builder()
// PhotoEditor с setClipSourceImage(true) должен обрезать черные полосы автоматически .setClearViewsEnabled(false)
val tempFile = File(context.cacheDir, "temp_${System.currentTimeMillis()}.png") .setTransparencyEnabled(true)
.setCompressFormat(Bitmap.CompressFormat.PNG)
.setCompressQuality(100)
.build()
val saveSettings = SaveSettings.Builder() val savedPath = suspendCancellableCoroutine<String?> { continuation ->
.setClearViewsEnabled(false) photoEditor.saveAsFile(
.setTransparencyEnabled(true) tempFile.absolutePath,
.build() saveSettings,
object : PhotoEditor.OnSaveListener {
val savedPath = suspendCancellableCoroutine<String?> { continuation -> override fun onSuccess(imagePath: String) {
photoEditor.saveAsFile( continuation.resume(imagePath)
tempFile.absolutePath,
saveSettings,
object : PhotoEditor.OnSaveListener {
override fun onSuccess(imagePath: String) {
continuation.resume(imagePath)
}
override fun onFailure(exception: Exception) {
continuation.resume(null)
}
} }
) override fun onFailure(exception: Exception) {
} continuation.resume(null)
}
if (savedPath == null) { }
// Ошибка сохранения - возвращаем оригинал
withContext(Dispatchers.Main) { onResult(imageUri) }
return@withContext
}
// Загружаем сохраненное изображение
val savedBitmap = BitmapFactory.decodeFile(savedPath)
if (savedBitmap == null) {
withContext(Dispatchers.Main) { onResult(Uri.fromFile(tempFile)) }
return@withContext
}
// Получаем размеры оригинального изображения
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(imageUri)?.use { stream ->
BitmapFactory.decodeStream(stream, null, options)
}
val imageWidth = options.outWidth
val imageHeight = options.outHeight
if (imageWidth <= 0 || imageHeight <= 0) {
withContext(Dispatchers.Main) { onResult(Uri.fromFile(tempFile)) }
return@withContext
}
val viewWidth = savedBitmap.width
val viewHeight = savedBitmap.height
// Соотношение сторон оригинала и сохраненного
val originalRatio = imageWidth.toFloat() / imageHeight
val savedRatio = viewWidth.toFloat() / viewHeight
// Если соотношения примерно равны - черных полос нет, возвращаем как есть
if (kotlin.math.abs(originalRatio - savedRatio) < 0.01f) {
savedBitmap.recycle()
withContext(Dispatchers.Main) { onResult(Uri.fromFile(tempFile)) }
return@withContext
}
// Вычисляем где находится изображение (FIT_CENTER логика)
val scale = minOf(
viewWidth.toFloat() / imageWidth,
viewHeight.toFloat() / imageHeight
) )
val scaledWidth = (imageWidth * scale).toInt()
val scaledHeight = (imageHeight * scale).toInt()
val left = ((viewWidth - scaledWidth) / 2).coerceAtLeast(0)
val top = ((viewHeight - scaledHeight) / 2).coerceAtLeast(0)
val cropWidth = scaledWidth.coerceIn(1, viewWidth - left)
val cropHeight = scaledHeight.coerceIn(1, viewHeight - top)
// Обрезаем черные полосы
val croppedBitmap = Bitmap.createBitmap(
savedBitmap,
left,
top,
cropWidth,
cropHeight
)
// Сохраняем обрезанное изображение
val finalFile = File(context.cacheDir, "edited_${System.currentTimeMillis()}.png")
java.io.FileOutputStream(finalFile).use { out ->
croppedBitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
savedBitmap.recycle()
croppedBitmap.recycle()
tempFile.delete()
withContext(Dispatchers.Main) {
onResult(Uri.fromFile(finalFile))
}
} catch (e: Exception) {
// При ошибке возвращаем оригинал
withContext(Dispatchers.Main) { onResult(imageUri) }
} }
// Возвращаем сохраненный файл без ручного кропа, чтобы не резать изображение
onResult(savedPath?.let { Uri.fromFile(File(it)) } ?: imageUri)
} catch (e: Exception) {
onResult(imageUri)
} }
} }
/** Save edited image synchronously - returns ORIGINAL URI without any processing */ /** Save edited image synchronously (with all editor changes). */
private suspend fun saveEditedImageSync( private suspend fun saveEditedImageSync(
context: Context, context: Context,
photoEditor: PhotoEditor?, photoEditor: PhotoEditor?,
photoEditorView: PhotoEditorView?, photoEditorView: PhotoEditorView?,
imageUri: Uri imageUri: Uri
): Uri? { ): Uri? {
// Просто возвращаем оригинальный URI без обработки return saveEditedImageSyncOld(
// PhotoEditor вызывает проблемы с обрезкой - пользователь получает оригинал context = context,
return imageUri photoEditor = photoEditor,
photoEditorView = photoEditorView,
imageUri = imageUri
)
} }
/** Save edited image synchronously - OLD VERSION with crop logic (disabled) */ /** Save edited image synchronously - OLD VERSION with crop logic (disabled) */
@@ -1318,109 +1275,37 @@ private suspend fun saveEditedImageSyncOld(
return imageUri return imageUri
} }
return withContext(Dispatchers.IO) { return try {
try { val tempFile = File(
val tempFile = File( context.cacheDir,
context.cacheDir, "edited_${System.currentTimeMillis()}_${(0..9999).random()}.png"
"temp_${System.currentTimeMillis()}_${(0..9999).random()}.png" )
)
val saveSettings = SaveSettings.Builder() val saveSettings = SaveSettings.Builder()
.setClearViewsEnabled(false) .setClearViewsEnabled(false)
.setTransparencyEnabled(true) .setTransparencyEnabled(true)
.build() .setCompressFormat(Bitmap.CompressFormat.PNG)
.setCompressQuality(100)
.build()
val savedPath = suspendCancellableCoroutine<String?> { continuation -> val savedPath = suspendCancellableCoroutine<String?> { continuation ->
photoEditor.saveAsFile( photoEditor.saveAsFile(
tempFile.absolutePath, tempFile.absolutePath,
saveSettings, saveSettings,
object : PhotoEditor.OnSaveListener { object : PhotoEditor.OnSaveListener {
override fun onSuccess(imagePath: String) { override fun onSuccess(imagePath: String) {
continuation.resume(imagePath) continuation.resume(imagePath)
}
override fun onFailure(exception: Exception) {
continuation.resume(null)
}
} }
) override fun onFailure(exception: Exception) {
} continuation.resume(null)
}
if (savedPath == null) { }
// Ошибка - возвращаем оригинал
return@withContext imageUri
}
// Загружаем сохраненное изображение
val savedBitmap = BitmapFactory.decodeFile(savedPath)
?: return@withContext Uri.fromFile(tempFile)
// Получаем размеры оригинального изображения
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(imageUri)?.use { stream ->
BitmapFactory.decodeStream(stream, null, options)
}
val imageWidth = options.outWidth
val imageHeight = options.outHeight
if (imageWidth <= 0 || imageHeight <= 0) {
return@withContext Uri.fromFile(tempFile)
}
val viewWidth = savedBitmap.width
val viewHeight = savedBitmap.height
// Соотношение сторон оригинала и сохраненного
val originalRatio = imageWidth.toFloat() / imageHeight
val savedRatio = viewWidth.toFloat() / viewHeight
// Если соотношения примерно равны - черных полос нет
if (kotlin.math.abs(originalRatio - savedRatio) < 0.01f) {
savedBitmap.recycle()
return@withContext Uri.fromFile(tempFile)
}
// Вычисляем где находится изображение (FIT_CENTER логика)
val scale = minOf(
viewWidth.toFloat() / imageWidth,
viewHeight.toFloat() / imageHeight
) )
val scaledWidth = (imageWidth * scale).toInt()
val scaledHeight = (imageHeight * scale).toInt()
val left = ((viewWidth - scaledWidth) / 2).coerceAtLeast(0)
val top = ((viewHeight - scaledHeight) / 2).coerceAtLeast(0)
val cropWidth = scaledWidth.coerceIn(1, viewWidth - left)
val cropHeight = scaledHeight.coerceIn(1, viewHeight - top)
// Обрезаем черные полосы
val croppedBitmap = Bitmap.createBitmap(
savedBitmap,
left,
top,
cropWidth,
cropHeight
)
// Сохраняем обрезанное изображение
val finalFile = File(
context.cacheDir,
"edited_${System.currentTimeMillis()}_${(0..9999).random()}.png"
)
java.io.FileOutputStream(finalFile).use { out ->
croppedBitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
savedBitmap.recycle()
croppedBitmap.recycle()
tempFile.delete()
Uri.fromFile(finalFile)
} catch (e: Exception) {
// При ошибке возвращаем оригинал
imageUri
} }
savedPath?.let { Uri.fromFile(File(it)) } ?: imageUri
} catch (e: Exception) {
imageUri
} }
} }
@@ -1474,6 +1359,7 @@ fun MultiImageEditorScreen(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val view = LocalView.current val view = LocalView.current
val focusManager = LocalFocusManager.current
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// 🎬 TELEGRAM-STYLE FADE ANIMATION // 🎬 TELEGRAM-STYLE FADE ANIMATION
@@ -1483,6 +1369,10 @@ fun MultiImageEditorScreen(
// Запуск enter анимации // Запуск enter анимации
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
animationProgress.animateTo( animationProgress.animateTo(
targetValue = 1f, targetValue = 1f,
animationSpec = tween( animationSpec = tween(
@@ -1615,14 +1505,13 @@ fun MultiImageEditorScreen(
// Загружаем изображение // Загружаем изображение
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
val inputStream = val bitmap = loadBitmapRespectExif(ctx, imagesWithCaptions[page].uri)
ctx.contentResolver.openInputStream(imagesWithCaptions[page].uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
source.apply { source.apply {
setImageBitmap(bitmap) if (bitmap != null) {
setImageBitmap(bitmap)
}
scaleType = ImageView.ScaleType.FIT_CENTER scaleType = ImageView.ScaleType.FIT_CENTER
adjustViewBounds = true adjustViewBounds = true
setPadding(0, 0, 0, 0) setPadding(0, 0, 0, 0)
@@ -1630,6 +1519,7 @@ fun MultiImageEditorScreen(
val editor = PhotoEditor.Builder(ctx, this@apply) val editor = PhotoEditor.Builder(ctx, this@apply)
.setPinchTextScalable(true) .setPinchTextScalable(true)
.setClipSourceImage(true)
.build() .build()
photoEditors[page] = editor photoEditors[page] = editor
} }
@@ -1645,14 +1535,14 @@ fun MultiImageEditorScreen(
if (currentUri != null) { if (currentUri != null) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
val inputStream = val bitmap = loadBitmapRespectExif(context, currentUri)
context.contentResolver.openInputStream(currentUri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
view.source.setImageBitmap(bitmap) if (bitmap != null) {
view.source.setImageBitmap(bitmap)
}
view.source.scaleType = ImageView.ScaleType.FIT_CENTER view.source.scaleType = ImageView.ScaleType.FIT_CENTER
view.source.adjustViewBounds = true
} }
} catch (e: Exception) { } catch (e: Exception) {
// Handle error // Handle error
@@ -1861,8 +1751,8 @@ fun MultiImageEditorScreen(
// 🚀 Сохраняем копию данных перед анимацией // 🚀 Сохраняем копию данных перед анимацией
val imagesToSend = imagesWithCaptions.toList() val imagesToSend = imagesWithCaptions.toList()
// 🎬 Запускаем fade-out анимацию сразу
scope.launch { scope.launch {
isSaving = true
val savedImages = mutableListOf<ImageWithCaption>() val savedImages = mutableListOf<ImageWithCaption>()
for (i in imagesToSend.indices) { for (i in imagesToSend.indices) {
@@ -1884,10 +1774,11 @@ fun MultiImageEditorScreen(
// Вызываем callback (он запустит sendImageGroup с optimistic UI) // Вызываем callback (он запустит sendImageGroup с optimistic UI)
onSendAll(savedImages) onSendAll(savedImages)
}
// ✈️ Плавно закрываем экран (fade-out) isSaving = false
animatedDismiss() // Закрываем после завершения сохранения/отправки
animatedDismiss()
}
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@@ -1907,6 +1798,60 @@ fun MultiImageEditorScreen(
} }
} }
private fun loadBitmapRespectExif(context: Context, uri: Uri): Bitmap? {
return try {
val orientation = readExifOrientation(context, uri)
val decodedBitmap =
context.contentResolver.openInputStream(uri)?.use { input ->
BitmapFactory.decodeStream(input)
} ?: return null
applyExifOrientation(decodedBitmap, orientation)
} catch (_: Exception) {
null
}
}
private fun readExifOrientation(context: Context, uri: Uri): Int {
return try {
context.contentResolver.openInputStream(uri)?.use { input ->
ExifInterface(input).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
} ?: ExifInterface.ORIENTATION_NORMAL
} catch (_: Exception) {
ExifInterface.ORIENTATION_NORMAL
}
}
private fun applyExifOrientation(bitmap: Bitmap, orientation: Int): Bitmap {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.preScale(-1f, 1f)
}
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(270f)
matrix.preScale(-1f, 1f)
}
else -> return bitmap
}
return try {
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true).also {
if (it != bitmap) bitmap.recycle()
}
} catch (_: Exception) {
bitmap
}
}
/** Async image loader */ /** Async image loader */
@Composable @Composable
private fun AsyncImageLoader(uri: Uri, modifier: Modifier = Modifier) { private fun AsyncImageLoader(uri: Uri, modifier: Modifier = Modifier) {

View File

@@ -1,5 +1,6 @@
package com.rosetta.messenger.ui.chats.components package com.rosetta.messenger.ui.chats.components
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.util.Base64 import android.util.Base64
@@ -62,6 +63,9 @@ import kotlin.math.absoluteValue
import kotlin.math.roundToInt import kotlin.math.roundToInt
private const val TAG = "ImageViewerScreen" private const val TAG = "ImageViewerScreen"
private const val EDGE_TAP_FADE_DURATION_MS = 120
private const val EDGE_TAP_FADE_START_ALPHA = 0.6f
private const val IMAGE_VIEWER_CACHE_SIZE = 8
/** /**
* Telegram-style CubicBezierInterpolator (0.25, 0.1, 0.25, 1.0) * Telegram-style CubicBezierInterpolator (0.25, 0.1, 0.25, 1.0)
@@ -212,6 +216,23 @@ fun ImageViewerScreen(
// UI visibility state // UI visibility state
var showControls by remember { mutableStateOf(true) } var showControls by remember { mutableStateOf(true) }
var isTapNavigationInProgress by remember { mutableStateOf(false) }
val edgeTapFadeAlpha = remember { Animatable(1f) }
val imageBitmapCache =
remember {
object : LinkedHashMap<String, Bitmap>(IMAGE_VIEWER_CACHE_SIZE, 0.75f, true) {
override fun removeEldestEntry(
eldest: MutableMap.MutableEntry<String, Bitmap>?
): Boolean = size > IMAGE_VIEWER_CACHE_SIZE
}
}
fun getCachedBitmap(attachmentId: String): Bitmap? =
synchronized(imageBitmapCache) { imageBitmapCache[attachmentId] }
fun cacheBitmap(attachmentId: String, bitmap: Bitmap) {
synchronized(imageBitmapCache) { imageBitmapCache[attachmentId] = bitmap }
}
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 🎬 SHARED ELEMENT TRANSFORM - вычисляем трансформацию // 🎬 SHARED ELEMENT TRANSFORM - вычисляем трансформацию
@@ -313,6 +334,25 @@ fun ImageViewerScreen(
val currentImage = images.getOrNull(pagerState.currentPage) val currentImage = images.getOrNull(pagerState.currentPage)
val dateFormat = remember { SimpleDateFormat("d MMMM, HH:mm", Locale.getDefault()) } val dateFormat = remember { SimpleDateFormat("d MMMM, HH:mm", Locale.getDefault()) }
// Prefetch ближайших изображений, чтобы при свайпе не было спиннера.
LaunchedEffect(pagerState.currentPage, images, privateKey) {
val current = pagerState.currentPage
val prefetchIndexes =
listOf(current - 2, current - 1, current + 1, current + 2).filter { it in images.indices }
prefetchIndexes.forEach { index ->
val prefetchImage = images[index]
if (getCachedBitmap(prefetchImage.attachmentId) != null) return@forEach
launch(Dispatchers.IO) {
val bitmap = loadBitmapForViewerImage(context, prefetchImage, privateKey)
if (bitmap != null) {
cacheBitmap(prefetchImage.attachmentId, bitmap)
}
}
}
}
BackHandler { BackHandler {
closeWithAnimation() closeWithAnimation()
} }
@@ -336,7 +376,7 @@ fun ImageViewerScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer { .graphicsLayer {
alpha = dismissAlpha.value alpha = dismissAlpha.value * edgeTapFadeAlpha.value
translationY = animatedOffsetY.value translationY = animatedOffsetY.value
// 🔥 Shared element transform при входе/выходе // 🔥 Shared element transform при входе/выходе
@@ -357,7 +397,7 @@ fun ImageViewerScreen(
pageSpacing = 30.dp, // Telegram: dp(30) между фото pageSpacing = 30.dp, // Telegram: dp(30) между фото
key = { images[it].attachmentId }, key = { images[it].attachmentId },
userScrollEnabled = !isAnimating, // Отключаем скролл во время анимации userScrollEnabled = !isAnimating, // Отключаем скролл во время анимации
beyondBoundsPageCount = 0, beyondBoundsPageCount = 1,
flingBehavior = PagerDefaults.flingBehavior( flingBehavior = PagerDefaults.flingBehavior(
state = pagerState, state = pagerState,
snapPositionalThreshold = 0.5f snapPositionalThreshold = 0.5f
@@ -393,21 +433,44 @@ fun ImageViewerScreen(
ZoomableImage( ZoomableImage(
image = image, image = image,
privateKey = privateKey, privateKey = privateKey,
cachedBitmap = getCachedBitmap(image.attachmentId),
onBitmapLoaded = { id, bitmap -> cacheBitmap(id, bitmap) },
onTap = { tapOffset -> onTap = { tapOffset ->
// 👆 Tap on left/right edge (20% zone) to navigate // 👆 Tap on left/right edge (20% zone) to navigate
val edgeZone = screenSize.width * 0.20f val edgeZone = screenSize.width * 0.20f
val tapX = tapOffset.x val tapX = tapOffset.x
val screenW = screenSize.width.toFloat() val screenW = screenSize.width.toFloat()
when {
tapX < edgeZone && pagerState.currentPage > 0 -> { fun animateToPage(targetPage: Int) {
scope.launch { if (targetPage == pagerState.currentPage) return
pagerState.scrollToPage(pagerState.currentPage - 1) if (isTapNavigationInProgress || pagerState.isScrollInProgress) return
scope.launch {
isTapNavigationInProgress = true
try {
// Мгновенный отклик: сразу переключаем страницу и даём короткий fade-in.
edgeTapFadeAlpha.stop()
edgeTapFadeAlpha.snapTo(EDGE_TAP_FADE_START_ALPHA)
pagerState.scrollToPage(targetPage)
edgeTapFadeAlpha.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = EDGE_TAP_FADE_DURATION_MS,
easing = FastOutSlowInEasing
)
)
} finally {
isTapNavigationInProgress = false
} }
} }
}
when {
tapX < edgeZone && pagerState.currentPage > 0 -> {
animateToPage(pagerState.currentPage - 1)
}
tapX > screenW - edgeZone && pagerState.currentPage < images.size - 1 -> { tapX > screenW - edgeZone && pagerState.currentPage < images.size - 1 -> {
scope.launch { animateToPage(pagerState.currentPage + 1)
pagerState.scrollToPage(pagerState.currentPage + 1)
}
} }
else -> showControls = !showControls else -> showControls = !showControls
} }
@@ -534,6 +597,8 @@ fun ImageViewerScreen(
private fun ZoomableImage( private fun ZoomableImage(
image: ViewableImage, image: ViewableImage,
privateKey: String, privateKey: String,
cachedBitmap: Bitmap? = null,
onBitmapLoaded: (String, Bitmap) -> Unit = { _, _ -> },
onTap: (Offset) -> Unit, onTap: (Offset) -> Unit,
onVerticalDrag: (Float, Float) -> Unit = { _, _ -> }, // dragAmount, velocity onVerticalDrag: (Float, Float) -> Unit = { _, _ -> }, // dragAmount, velocity
onDragEnd: () -> Unit = {} onDragEnd: () -> Unit = {}
@@ -541,9 +606,9 @@ private fun ZoomableImage(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var bitmap by remember { mutableStateOf<Bitmap?>(null) } var bitmap by remember(image.attachmentId, cachedBitmap) { mutableStateOf(cachedBitmap) }
var previewBitmap by remember { mutableStateOf<Bitmap?>(null) } var previewBitmap by remember { mutableStateOf<Bitmap?>(null) }
var isLoading by remember { mutableStateOf(true) } var isLoading by remember(image.attachmentId, cachedBitmap) { mutableStateOf(cachedBitmap == null) }
var loadError by remember { mutableStateOf<String?>(null) } var loadError by remember { mutableStateOf<String?>(null) }
// Zoom and pan state with animation // Zoom and pan state with animation
@@ -589,68 +654,26 @@ private fun ZoomableImage(
} }
// Load image // Load image
LaunchedEffect(image.attachmentId) { LaunchedEffect(image.attachmentId, cachedBitmap) {
if (cachedBitmap != null) {
bitmap = cachedBitmap
loadError = null
isLoading = false
return@LaunchedEffect
}
isLoading = true isLoading = true
loadError = null loadError = null
withContext(Dispatchers.IO) { val loadedBitmap = withContext(Dispatchers.IO) { loadBitmapForViewerImage(context, image, privateKey) }
try { if (loadedBitmap != null) {
// 1. Если blob уже есть bitmap = loadedBitmap
if (image.blob.isNotEmpty()) { onBitmapLoaded(image.attachmentId, loadedBitmap)
bitmap = base64ToBitmapSafe(image.blob) loadError = null
if (bitmap != null) { } else {
isLoading = false loadError = "Failed to load image"
return@withContext
}
}
// 2. Пробуем из локального файла
val localBlob = AttachmentFileManager.readAttachment(
context, image.attachmentId, image.senderPublicKey, privateKey
)
if (localBlob != null) {
bitmap = base64ToBitmapSafe(localBlob)
if (bitmap != null) {
isLoading = false
return@withContext
}
}
// 3. Скачиваем с CDN
val downloadTag = getDownloadTag(image.preview)
if (downloadTag.isNotEmpty()) {
val encryptedContent = TransportManager.downloadFile(image.attachmentId, downloadTag)
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey)
val decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(
encryptedContent,
decryptedKeyAndNonce
)
if (decrypted != null) {
bitmap = base64ToBitmapSafe(decrypted)
// Сохраняем локально
AttachmentFileManager.saveAttachment(
context = context,
blob = decrypted,
attachmentId = image.attachmentId,
publicKey = image.senderPublicKey,
privateKey = privateKey
)
}
}
if (bitmap == null) {
loadError = "Failed to load image"
}
} catch (e: Exception) {
loadError = e.message
}
isLoading = false
} }
isLoading = false
} }
// Reset zoom when image changes // Reset zoom when image changes
@@ -866,6 +889,57 @@ private fun ZoomableImage(
} }
} }
/**
* Загружает Bitmap для ImageViewer:
* 1) из in-message blob
* 2) из локального encrypted attachment файла
* 3) с transport (с последующим сохранением в локальный файл)
*/
private suspend fun loadBitmapForViewerImage(
context: Context,
image: ViewableImage,
privateKey: String
): Bitmap? {
return try {
// 1. Если blob уже есть в сообщении
if (image.blob.isNotEmpty()) {
base64ToBitmapSafe(image.blob)?.let { return it }
}
// 2. Пробуем прочитать из локального encrypted cache
val localBlob =
AttachmentFileManager.readAttachment(context, image.attachmentId, image.senderPublicKey, privateKey)
if (localBlob != null) {
base64ToBitmapSafe(localBlob)?.let { return it }
}
// 3. Скачиваем и расшифровываем с transport
val downloadTag = getDownloadTag(image.preview)
if (downloadTag.isEmpty()) return null
val encryptedContent = TransportManager.downloadFile(image.attachmentId, downloadTag)
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey)
val decrypted =
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
?: return null
val decodedBitmap = base64ToBitmapSafe(decrypted) ?: return null
// Сохраняем локально для следующих открытий/свайпов
AttachmentFileManager.saveAttachment(
context = context,
blob = decrypted,
attachmentId = image.attachmentId,
publicKey = image.senderPublicKey,
privateKey = privateKey
)
decodedBitmap
} catch (_: Exception) {
null
}
}
/** /**
* Безопасное декодирование base64 в Bitmap * Безопасное декодирование base64 в Bitmap
*/ */

View File

@@ -210,7 +210,7 @@ fun MediaPickerBottomSheet(
val screenHeightPx = with(density) { screenHeight.toPx() } val screenHeightPx = with(density) { screenHeight.toPx() }
// 🔄 Высоты в пикселях для точного контроля // 🔄 Высоты в пикселях для точного контроля
val collapsedHeightPx = screenHeightPx * 0.45f // Свёрнутое - 45% экрана val collapsedHeightPx = screenHeightPx * 0.50f // Свёрнутое - 50% экрана (легче тянуть дальше вверх)
val expandedHeightPx = screenHeightPx * 0.88f // Развёрнутое - 88% экрана val expandedHeightPx = screenHeightPx * 0.88f // Развёрнутое - 88% экрана
val minHeightPx = screenHeightPx * 0.2f // Минимум при свайпе вниз val minHeightPx = screenHeightPx * 0.2f // Минимум при свайпе вниз
@@ -303,7 +303,8 @@ fun MediaPickerBottomSheet(
// Пороги основаны на velocity (скорости свайпа) - не на позиции! // Пороги основаны на velocity (скорости свайпа) - не на позиции!
// velocity < 0 = свайп вверх, velocity > 0 = свайп вниз // velocity < 0 = свайп вверх, velocity > 0 = свайп вниз
val velocityThreshold = 300f // Маленький порог для лёгкого свайпа val velocityThreshold = 180f // Более низкий порог: легче раскрыть sheet свайпом вверх
val expandSnapThreshold = collapsedHeightPx + (expandedHeightPx - collapsedHeightPx) * 0.35f
when { when {
// Быстрый свайп вниз при минимальной высоте - закрываем // Быстрый свайп вниз при минимальной высоте - закрываем
@@ -341,7 +342,7 @@ fun MediaPickerBottomSheet(
} }
} }
// Без velocity - snap к ближайшему // Без velocity - snap к ближайшему
currentHeight > (collapsedHeightPx + expandedHeightPx) / 2 -> { currentHeight > expandSnapThreshold -> {
isExpanded = true isExpanded = true
sheetHeightPx.animateTo( sheetHeightPx.animateTo(
expandedHeightPx, expandedHeightPx,
@@ -484,12 +485,13 @@ fun MediaPickerBottomSheet(
}, },
onVerticalDrag = { change, dragAmount -> onVerticalDrag = { change, dragAmount ->
change.consume() change.consume()
// Сохраняем velocity (dragAmount в пикселях) // Для подъёма вверх усиливаем жест, вниз делаем немного мягче.
// Усиливаем чувствительность в 1.5 раза val adjustedDragAmount =
lastDragVelocity = dragAmount * 1.5f if (dragAmount < 0f) dragAmount * 1.25f else dragAmount * 0.9f
lastDragVelocity = adjustedDragAmount * 1.8f
// 🔥 Меняем высоту в реальном времени // 🔥 Меняем высоту в реальном времени
val newHeight = (sheetHeightPx.value - dragAmount) val newHeight = (sheetHeightPx.value - adjustedDragAmount)
.coerceIn(minHeightPx, expandedHeightPx) .coerceIn(minHeightPx, expandedHeightPx)
animationScope.launch { animationScope.launch {
sheetHeightPx.snapTo(newHeight) sheetHeightPx.snapTo(newHeight)

View File

@@ -89,6 +89,23 @@ fun MessageInputBar(
inputFocusTrigger: Int = 0 inputFocusTrigger: Int = 0
) { ) {
val hasReply = replyMessages.isNotEmpty() val hasReply = replyMessages.isNotEmpty()
val liveReplyMessages =
remember(replyMessages, displayReplyMessages) {
if (displayReplyMessages.isNotEmpty()) displayReplyMessages else replyMessages
}
var animatedReplyMessages by remember {
mutableStateOf<List<ChatViewModel.ReplyMessage>>(emptyList())
}
var animatedForwardMode by remember { mutableStateOf(isForwardMode) }
LaunchedEffect(liveReplyMessages, isForwardMode) {
if (liveReplyMessages.isNotEmpty()) {
animatedReplyMessages = liveReplyMessages
animatedForwardMode = isForwardMode
}
}
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
@@ -309,12 +326,19 @@ fun MessageInputBar(
shrinkTowards = Alignment.Bottom shrinkTowards = Alignment.Bottom
) )
) { ) {
val panelReplyMessages =
if (liveReplyMessages.isNotEmpty()) liveReplyMessages
else animatedReplyMessages
val panelIsForwardMode =
if (liveReplyMessages.isNotEmpty()) isForwardMode
else animatedForwardMode
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable {
if (displayReplyMessages.isNotEmpty()) { if (panelReplyMessages.isNotEmpty()) {
onReplyClick(displayReplyMessages.first().messageId) onReplyClick(panelReplyMessages.first().messageId)
} }
} }
.background(backgroundColor) .background(backgroundColor)
@@ -330,30 +354,30 @@ fun MessageInputBar(
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = if (isForwardMode) text = if (panelIsForwardMode)
"Forward message${if (displayReplyMessages.size > 1) "s" else ""}" "Forward message${if (panelReplyMessages.size > 1) "s" else ""}"
else else
"Reply to ${if (displayReplyMessages.size == 1 && !displayReplyMessages.first().isOutgoing) chatTitle else "You"}", "Reply to ${if (panelReplyMessages.size == 1 && !panelReplyMessages.first().isOutgoing) chatTitle else "You"}",
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = PrimaryBlue, color = PrimaryBlue,
maxLines = 1 maxLines = 1
) )
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
if (displayReplyMessages.isNotEmpty()) { if (panelReplyMessages.isNotEmpty()) {
val msg = displayReplyMessages.first() val msg = panelReplyMessages.first()
val hasImageAttachment = msg.attachments.any { val hasImageAttachment = msg.attachments.any {
it.type == AttachmentType.IMAGE it.type == AttachmentType.IMAGE
} }
AppleEmojiText( AppleEmojiText(
text = if (displayReplyMessages.size == 1) { text = if (panelReplyMessages.size == 1) {
if (msg.text.isEmpty() && hasImageAttachment) { if (msg.text.isEmpty() && hasImageAttachment) {
"Photo" "Photo"
} else { } else {
val shortText = msg.text.take(40) val shortText = msg.text.take(40)
if (shortText.length < msg.text.length) "$shortText..." else shortText if (shortText.length < msg.text.length) "$shortText..." else shortText
} }
} else "${displayReplyMessages.size} messages", } else "${panelReplyMessages.size} messages",
fontSize = 13.sp, fontSize = 13.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) color = if (isDarkTheme) Color.White.copy(alpha = 0.6f)
else Color.Black.copy(alpha = 0.5f), else Color.Black.copy(alpha = 0.5f),
@@ -365,8 +389,8 @@ fun MessageInputBar(
} }
// 🔥 Превью изображения (как в Telegram) // 🔥 Превью изображения (как в Telegram)
if (displayReplyMessages.size == 1) { if (panelReplyMessages.size == 1) {
val msg = displayReplyMessages.first() val msg = panelReplyMessages.first()
val imageAttachment = msg.attachments.find { val imageAttachment = msg.attachments.find {
it.type == AttachmentType.IMAGE it.type == AttachmentType.IMAGE
} }