fix: implement optimistic UI for image sending and enhance attachment handling

This commit is contained in:
2026-02-03 23:40:43 +05:00
parent 44a816dedb
commit dd7d7b19cf
5 changed files with 406 additions and 80 deletions

View File

@@ -265,7 +265,8 @@ data class MessageAttachment(
val type: AttachmentType,
val preview: String = "", // Метаданные: "UUID::metadata" или "filesize::filename"
val width: Int = 0,
val height: Int = 0
val height: Int = 0,
val localUri: String = "" // 🚀 Локальный URI для мгновенного отображения (optimistic UI)
)
/**

View File

@@ -2151,27 +2151,12 @@ fun ChatDetailScreen(
pendingCameraPhotoUri = null
},
onSave = { editedUri ->
// Fallback если onSaveWithCaption не сработал
pendingCameraPhotoUri = null
scope.launch {
val base64 = MediaUtils.uriToBase64Image(context, editedUri)
val blurhash = MediaUtils.generateBlurhash(context, editedUri)
val (width, height) = MediaUtils.getImageDimensions(context, editedUri)
if (base64 != null) {
viewModel.sendImageMessage(base64, blurhash, "", width, height)
}
}
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
viewModel.sendImageFromUri(editedUri, "")
},
onSaveWithCaption = { editedUri, caption ->
pendingCameraPhotoUri = null
scope.launch {
val base64 = MediaUtils.uriToBase64Image(context, editedUri)
val blurhash = MediaUtils.generateBlurhash(context, editedUri)
val (width, height) = MediaUtils.getImageDimensions(context, editedUri)
if (base64 != null) {
viewModel.sendImageMessage(base64, blurhash, caption, width, height)
}
}
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
viewModel.sendImageFromUri(editedUri, caption)
},
isDarkTheme = isDarkTheme,
showCaptionInput = true,
@@ -2187,33 +2172,9 @@ fun ChatDetailScreen(
pendingGalleryImages = emptyList()
},
onSendAll = { imagesWithCaptions ->
pendingGalleryImages = emptyList()
scope.launch {
// Собираем все изображения с их caption
val imageDataList = mutableListOf<ChatViewModel.ImageData>()
for (imageWithCaption in imagesWithCaptions) {
val base64 = MediaUtils.uriToBase64Image(context, imageWithCaption.uri)
val blurhash = MediaUtils.generateBlurhash(context, imageWithCaption.uri)
val (width, height) = MediaUtils.getImageDimensions(context, imageWithCaption.uri)
if (base64 != null) {
imageDataList.add(ChatViewModel.ImageData(base64, blurhash, width, height))
}
}
// Если одно фото с caption - отправляем как обычное сообщение
if (imageDataList.size == 1 && imagesWithCaptions[0].caption.isNotBlank()) {
viewModel.sendImageMessage(
imageDataList[0].base64,
imageDataList[0].blurhash,
imagesWithCaptions[0].caption,
imageDataList[0].width,
imageDataList[0].height
)
} else if (imageDataList.isNotEmpty()) {
// Отправляем группой (коллаж)
viewModel.sendImageGroup(imageDataList)
}
// 🚀 Мгновенный optimistic UI для каждого фото
for (imageWithCaption in imagesWithCaptions) {
viewModel.sendImageFromUri(imageWithCaption.uri, imageWithCaption.caption)
}
},
isDarkTheme = isDarkTheme,

View File

@@ -1467,6 +1467,235 @@ val newList = messages + optimisticMessages
}
}
/**
* 📸🚀 Отправка изображения по URI с МГНОВЕННЫМ optimistic UI
* Фото появляется в чате СРАЗУ, конвертация и отправка происходят в фоне
*
* @param imageUri URI изображения
* @param caption Подпись к изображению
*/
fun sendImageFromUri(imageUri: android.net.Uri, caption: String = "") {
val recipient = opponentKey
val sender = myPublicKey
val privateKey = myPrivateKey
val context = getApplication<Application>()
if (recipient == null || sender == null || privateKey == null) {
return
}
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val text = caption.trim()
// 1. 🚀 МГНОВЕННО показываем optimistic сообщение с localUri
// Используем URI напрямую для отображения (без конвертации в base64)
val optimisticMessage = ChatMessage(
id = messageId,
text = text,
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
attachments = listOf(
MessageAttachment(
id = "img_$timestamp",
blob = "", // Пока пустой, обновим после конвертации
type = AttachmentType.IMAGE,
preview = "", // Пока пустой, обновим после генерации
width = 0,
height = 0,
localUri = imageUri.toString() // 🔥 Используем localUri для мгновенного показа
)
)
)
addMessageSafely(optimisticMessage)
_inputText.value = ""
// 2. 🔄 В ФОНЕ: конвертируем, генерируем blurhash и отправляем
viewModelScope.launch(Dispatchers.IO) {
try {
// Получаем размеры изображения
val (width, height) = com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri)
// Конвертируем в base64
val imageBase64 = com.rosetta.messenger.utils.MediaUtils.uriToBase64Image(context, imageUri)
if (imageBase64 == null) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.ERROR)
}
return@launch
}
// Генерируем blurhash
val blurhash = com.rosetta.messenger.utils.MediaUtils.generateBlurhash(context, imageUri)
// 3. 🔄 Обновляем optimistic сообщение с реальными данными
withContext(Dispatchers.Main) {
updateOptimisticImageMessage(messageId, imageBase64, blurhash, width, height)
}
// 4. 📤 Отправляем (шифрование + загрузка на сервер)
sendImageMessageInternal(
messageId = messageId,
imageBase64 = imageBase64,
blurhash = blurhash,
caption = text,
width = width,
height = height,
timestamp = timestamp,
recipient = recipient,
sender = sender,
privateKey = privateKey
)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.ERROR)
}
}
}
}
/**
* 🔄 Обновляет optimistic сообщение с реальными данными изображения
*/
private fun updateOptimisticImageMessage(messageId: String, base64: String, blurhash: String, width: Int, height: Int) {
val currentMessages = _messages.value.toMutableList()
val index = currentMessages.indexOfFirst { it.id == messageId }
if (index != -1) {
val message = currentMessages[index]
val updatedAttachments = message.attachments.map { att ->
if (att.type == AttachmentType.IMAGE) {
att.copy(
preview = blurhash,
blob = base64,
width = width,
height = height
)
} else att
}
currentMessages[index] = message.copy(attachments = updatedAttachments)
_messages.value = currentMessages
}
}
/**
* 📤 Внутренняя функция отправки изображения (уже с готовым base64)
*/
private suspend fun sendImageMessageInternal(
messageId: String,
imageBase64: String,
blurhash: String,
caption: String,
width: Int,
height: Int,
timestamp: Long,
recipient: String,
sender: String,
privateKey: String
) {
try {
val context = getApplication<Application>()
// Шифрование текста
val encryptResult = MessageCrypto.encryptForSending(caption, recipient)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
// 🚀 Шифруем изображение с ChaCha ключом для Transport Server
val encryptedImageBlob = MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce)
val attachmentId = "img_$timestamp"
// 📤 Загружаем на Transport Server
val isSavedMessages = (sender == recipient)
var uploadTag = ""
if (!isSavedMessages) {
uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
}
// Preview содержит tag::blurhash
val previewWithTag = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash
val imageAttachment = MessageAttachment(
id = attachmentId,
blob = "",
type = AttachmentType.IMAGE,
preview = previewWithTag,
width = width,
height = height
)
val packet = PacketMessage().apply {
fromPublicKey = sender
toPublicKey = recipient
content = encryptedContent
chachaKey = encryptedKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
attachments = listOf(imageAttachment)
}
// Отправляем пакет
if (!isSavedMessages) {
ProtocolManager.send(packet)
}
// 💾 Сохраняем изображение в файл локально
AttachmentFileManager.saveAttachment(
context = context,
blob = imageBase64,
attachmentId = attachmentId,
publicKey = sender,
privateKey = privateKey
)
// Сохраняем в БД
val attachmentsJson = JSONArray().apply {
put(JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.IMAGE.value)
put("preview", previewWithTag)
put("blob", "")
put("width", width)
put("height", height)
})
}.toString()
saveMessageToDatabase(
messageId = messageId,
text = caption,
encryptedContent = encryptedContent,
encryptedKey = encryptedKey,
timestamp = timestamp,
isFromMe = true,
delivered = if (isSavedMessages) 2 else 0,
attachmentsJson = attachmentsJson
)
// Обновляем статус на SENT
if (!isSavedMessages) {
updateMessageStatusInDb(messageId, 2)
}
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
saveDialog(if (caption.isNotEmpty()) caption else "photo", timestamp)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.ERROR)
}
}
}
/**
* 📸 Отправка сообщения с изображением
* @param imageBase64 Base64 изображения

View File

@@ -2,6 +2,7 @@ package com.rosetta.messenger.ui.chats.components
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.util.Base64
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState
@@ -33,6 +34,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.exifinterface.media.ExifInterface
import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.MessageAttachment
@@ -538,11 +540,15 @@ fun ImageAttachment(
)
// Определяем начальный статус и декодируем blurhash (как в Desktop calcDownloadStatus)
LaunchedEffect(attachment.id) {
LaunchedEffect(attachment.id, attachment.localUri) {
// Определяем статус (логика из Desktop useAttachment.ts)
withContext(Dispatchers.IO) {
downloadStatus =
when {
// 🚀 0. Если есть localUri - это optimistic UI, показываем сразу
attachment.localUri.isNotEmpty() -> {
DownloadStatus.DOWNLOADED
}
// 1. Если blob уже есть в памяти → DOWNLOADED
attachment.blob.isNotEmpty() -> {
DownloadStatus.DOWNLOADED
@@ -587,11 +593,67 @@ fun ImageAttachment(
// Загружаем изображение если статус DOWNLOADED
if (downloadStatus == DownloadStatus.DOWNLOADED) {
withContext(Dispatchers.IO) {
// 1. Сначала пробуем blob из памяти
if (attachment.blob.isNotEmpty()) {
// 🚀 0. Если есть localUri - загружаем напрямую из URI (optimistic UI)
if (attachment.localUri.isNotEmpty()) {
try {
val uri = android.net.Uri.parse(attachment.localUri)
// 📐 Читаем EXIF ориентацию
val orientation = try {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
val exif = ExifInterface(inputStream)
exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
} ?: ExifInterface.ORIENTATION_NORMAL
} catch (e: Exception) {
ExifInterface.ORIENTATION_NORMAL
}
// Загружаем bitmap
val inputStream = context.contentResolver.openInputStream(uri)
var bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
// 🔄 Применяем EXIF ориентацию
if (bitmap != null && orientation != ExifInterface.ORIENTATION_NORMAL) {
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)
}
}
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
if (rotated != bitmap) {
bitmap.recycle()
bitmap = rotated
}
}
imageBitmap = bitmap
} catch (e: Exception) {
// Fallback to other methods
}
}
// 1. Если нет bitmap, пробуем blob из памяти
if (imageBitmap == null && attachment.blob.isNotEmpty()) {
imageBitmap = base64ToBitmap(attachment.blob)
} else {
// 2. Читаем из файловой системы (как в Desktop getBlob)
}
// 2. Если всё ещё нет, читаем из файловой системы
if (imageBitmap == null) {
val localBlob =
AttachmentFileManager.readAttachment(
context,
@@ -601,7 +663,8 @@ fun ImageAttachment(
)
if (localBlob != null) {
imageBitmap = base64ToBitmap(localBlob)
} else {
} else if (attachment.localUri.isEmpty()) {
// Только если нет localUri - помечаем как NOT_DOWNLOADED
downloadStatus = DownloadStatus.NOT_DOWNLOADED
}
}
@@ -902,6 +965,30 @@ fun ImageAttachment(
}
}
// ✈️ Telegram-style: Loader при отправке фото (самолётик/кружок)
// Показываем когда сообщение отправляется И это исходящее сообщение
if (isOutgoing && messageStatus == MessageStatus.SENDING && downloadStatus == DownloadStatus.DOWNLOADED) {
Box(
modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)),
contentAlignment = Alignment.Center
) {
// Круглый индикатор отправки
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(28.dp),
color = Color.White,
strokeWidth = 2.5.dp
)
}
}
}
// Оверлей для статуса скачивания
if (downloadStatus != DownloadStatus.DOWNLOADED) {
Box(

View File

@@ -603,19 +603,27 @@ fun ImageEditorScreen(
onToggleEmojiPicker = { toggleEmojiPicker() },
onEditTextViewCreated = { editTextView = it },
onSend = {
// 🚀 Сохраняем caption для использования после анимации
val captionToSend = caption
val uriToSend = currentImageUri
// ✈️ Сразу запускаем fade-out анимацию (как в Telegram)
// Фото появится в чате через optimistic UI
scope.launch {
isSaving = true
saveEditedImage(context, photoEditor, photoEditorView, currentImageUri) { savedUri ->
isSaving = false
saveEditedImage(context, photoEditor, photoEditorView, uriToSend) { savedUri ->
if (savedUri != null) {
// Вызываем callback (он запустит sendImageMessage с optimistic UI)
if (onSaveWithCaption != null) {
onSaveWithCaption(savedUri, caption)
onSaveWithCaption(savedUri, captionToSend)
} else {
onSave(savedUri)
}
}
}
}
// 🎬 Плавно закрываем экран (fade-out)
animatedDismiss()
}
)
@@ -1478,6 +1486,39 @@ fun MultiImageEditorScreen(
val scope = rememberCoroutineScope()
val view = LocalView.current
// ═══════════════════════════════════════════════════════════════
// 🎬 TELEGRAM-STYLE FADE ANIMATION
// ═══════════════════════════════════════════════════════════════
var isClosing by remember { mutableStateOf(false) }
val animationProgress = remember { Animatable(0f) }
// Запуск enter анимации
LaunchedEffect(Unit) {
animationProgress.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = 200,
easing = FastOutSlowInEasing
)
)
}
// Функция для плавного закрытия
fun animatedDismiss() {
if (isClosing) return
isClosing = true
scope.launch {
animationProgress.animateTo(
targetValue = 0f,
animationSpec = tween(
durationMillis = 200,
easing = FastOutSlowInEasing
)
)
onDismiss()
}
}
// 🎨 Черный статус бар и навигационный бар для редактора
val activity = context as? Activity
val window = activity?.window
@@ -1554,12 +1595,20 @@ fun MultiImageEditorScreen(
}
}
BackHandler { onDismiss() }
BackHandler { animatedDismiss() }
// 🎬 Анимированный контейнер с fade эффектом
val progress = animationProgress.value
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.background(Color.Black.copy(alpha = progress))
.graphicsLayer {
alpha = progress
scaleX = 0.95f + 0.05f * progress
scaleY = 0.95f + 0.05f * progress
}
) {
// Pager
HorizontalPager(
@@ -1646,7 +1695,7 @@ fun MultiImageEditorScreen(
.padding(horizontal = 4.dp, vertical = 8.dp)
) {
IconButton(
onClick = onDismiss,
onClick = { animatedDismiss() },
modifier = Modifier.align(Alignment.CenterStart)
) {
Icon(
@@ -1829,15 +1878,18 @@ fun MultiImageEditorScreen(
.size(48.dp)
.clip(CircleShape)
.background(PrimaryBlue)
.clickable(enabled = !isSaving) {
isSaving = true
.clickable(enabled = !isSaving && !isClosing) {
// 🚀 Сохраняем копию данных перед анимацией
val imagesToSend = imagesWithCaptions.toList()
// 🎬 Запускаем fade-out анимацию сразу
scope.launch {
val savedImages = mutableListOf<ImageWithCaption>()
for (i in imagesWithCaptions.indices) {
for (i in imagesToSend.indices) {
val editor = photoEditors[i]
val editorView = photoEditorViews[i]
val originalImage = imagesWithCaptions[i]
val originalImage = imagesToSend[i]
if (editor != null) {
val savedUri = saveEditedImageSync(context, editor, editorView, originalImage.uri)
@@ -1851,27 +1903,23 @@ fun MultiImageEditorScreen(
}
}
// Вызываем callback (он запустит sendImageGroup с optimistic UI)
onSendAll(savedImages)
}
// ✈️ Плавно закрываем экран (fade-out)
animatedDismiss()
},
contentAlignment = Alignment.Center
) {
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(22.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Icon(
TablerIcons.Send,
contentDescription = "Send",
tint = Color.White,
modifier = Modifier
.size(22.dp)
.offset(x = 1.dp)
)
}
Icon(
TablerIcons.Send,
contentDescription = "Send",
tint = Color.White,
modifier = Modifier
.size(22.dp)
.offset(x = 1.dp)
)
}
}