feat: Implement file and avatar attachment handling in chat messages
This commit is contained in:
@@ -1612,6 +1612,10 @@ fun ChatDetailScreen(
|
||||
message.id,
|
||||
isSavedMessages =
|
||||
isSavedMessages,
|
||||
privateKey =
|
||||
currentUserPrivateKey,
|
||||
senderPublicKey =
|
||||
if (message.isOutgoing) currentUserPublicKey else user.publicKey,
|
||||
onLongClick = {
|
||||
if (!isSelectionMode
|
||||
) {
|
||||
|
||||
@@ -719,6 +719,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
} else {
|
||||
}
|
||||
|
||||
// Парсим все attachments (IMAGE, FILE, AVATAR)
|
||||
val parsedAttachments = parseAllAttachments(entity.attachments)
|
||||
|
||||
return ChatMessage(
|
||||
id = entity.messageId,
|
||||
@@ -732,10 +734,50 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
3 -> MessageStatus.READ
|
||||
else -> MessageStatus.SENT
|
||||
},
|
||||
replyData = replyData
|
||||
replyData = replyData,
|
||||
attachments = parsedAttachments,
|
||||
chachaKey = entity.chachaKey
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсинг всех attachments из JSON (кроме MESSAGES который обрабатывается отдельно)
|
||||
*/
|
||||
private fun parseAllAttachments(attachmentsJson: String): List<MessageAttachment> {
|
||||
if (attachmentsJson.isEmpty() || attachmentsJson == "[]") {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return try {
|
||||
val attachments = JSONArray(attachmentsJson)
|
||||
val result = mutableListOf<MessageAttachment>()
|
||||
|
||||
for (i in 0 until attachments.length()) {
|
||||
val attachment = attachments.getJSONObject(i)
|
||||
val type = attachment.optInt("type", 0)
|
||||
|
||||
// Пропускаем MESSAGES (1) - это reply, обрабатывается отдельно
|
||||
if (type == 1) continue
|
||||
|
||||
result.add(
|
||||
MessageAttachment(
|
||||
id = attachment.optString("id", ""),
|
||||
blob = attachment.optString("blob", ""),
|
||||
type = AttachmentType.fromInt(type),
|
||||
preview = attachment.optString("preview", ""),
|
||||
width = attachment.optInt("width", 0),
|
||||
height = attachment.optInt("height", 0)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ChatViewModel", "Failed to parse attachments", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсинг reply из текста сообщения (fallback формат)
|
||||
* Формат: "🇵 Reply: "текст цитаты"\n\nосновной текст"
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
package com.rosetta.messenger.ui.chats.components
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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 com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.crypto.MessageCrypto
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.MessageAttachment
|
||||
import com.rosetta.messenger.network.TransportManager
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.utils.AvatarFileManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Статус скачивания attachment
|
||||
*/
|
||||
enum class DownloadStatus {
|
||||
DOWNLOADED,
|
||||
NOT_DOWNLOADED,
|
||||
PENDING,
|
||||
DECRYPTING,
|
||||
DOWNLOADING,
|
||||
ERROR
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable для отображения всех attachments в сообщении
|
||||
*/
|
||||
@Composable
|
||||
fun MessageAttachments(
|
||||
attachments: List<MessageAttachment>,
|
||||
chachaKey: String,
|
||||
privateKey: String,
|
||||
isOutgoing: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
senderPublicKey: String,
|
||||
avatarRepository: AvatarRepository? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (attachments.isEmpty()) return
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
attachments.forEach { attachment ->
|
||||
when (attachment.type) {
|
||||
AttachmentType.IMAGE -> {
|
||||
ImageAttachment(
|
||||
attachment = attachment,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
AttachmentType.FILE -> {
|
||||
FileAttachment(
|
||||
attachment = attachment,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
AttachmentType.AVATAR -> {
|
||||
AvatarAttachment(
|
||||
attachment = attachment,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
senderPublicKey = senderPublicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
AttachmentType.MESSAGES -> {
|
||||
// MESSAGES обрабатываются отдельно как reply
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Image attachment
|
||||
*/
|
||||
@Composable
|
||||
fun ImageAttachment(
|
||||
attachment: MessageAttachment,
|
||||
chachaKey: String,
|
||||
privateKey: String,
|
||||
isDarkTheme: Boolean
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
|
||||
var imageBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var downloadProgress by remember { mutableStateOf(0) }
|
||||
|
||||
// Проверяем статус при загрузке
|
||||
LaunchedEffect(attachment.id) {
|
||||
downloadStatus = if (attachment.blob.isNotEmpty() && !isDownloadTag(attachment.preview)) {
|
||||
// Blob уже есть - сразу декодируем
|
||||
DownloadStatus.DOWNLOADED
|
||||
} else if (isDownloadTag(attachment.preview)) {
|
||||
// Нужно скачать с сервера
|
||||
DownloadStatus.NOT_DOWNLOADED
|
||||
} else {
|
||||
DownloadStatus.DOWNLOADED
|
||||
}
|
||||
|
||||
// Если скачано - декодируем изображение
|
||||
if (downloadStatus == DownloadStatus.DOWNLOADED && attachment.blob.isNotEmpty()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
imageBitmap = base64ToBitmap(attachment.blob)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val download: () -> Unit = {
|
||||
scope.launch {
|
||||
try {
|
||||
downloadStatus = DownloadStatus.DOWNLOADING
|
||||
|
||||
val tag = getDownloadTag(attachment.preview)
|
||||
if (tag.isEmpty()) {
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Скачиваем с транспортного сервера
|
||||
val encryptedContent = TransportManager.downloadFile(attachment.id, tag)
|
||||
|
||||
downloadStatus = DownloadStatus.DECRYPTING
|
||||
|
||||
// Расшифровываем с ChaCha ключом
|
||||
val decrypted = MessageCrypto.decryptAttachmentBlob(
|
||||
encryptedContent,
|
||||
chachaKey,
|
||||
privateKey
|
||||
)
|
||||
|
||||
if (decrypted != null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
imageBitmap = base64ToBitmap(decrypted)
|
||||
}
|
||||
downloadStatus = DownloadStatus.DOWNLOADED
|
||||
} else {
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ImageAttachment", "Download failed", e)
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable {
|
||||
if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) {
|
||||
download()
|
||||
}
|
||||
},
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 100.dp, max = 300.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (downloadStatus) {
|
||||
DownloadStatus.DOWNLOADED -> {
|
||||
imageBitmap?.let { bitmap ->
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = "Image",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
}
|
||||
DownloadStatus.NOT_DOWNLOADED -> {
|
||||
// Показываем preview (blurhash) если есть
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Image,
|
||||
contentDescription = null,
|
||||
tint = if (isDarkTheme) Color.White.copy(alpha = 0.7f) else Color.Gray,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = download,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PrimaryBlue
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Download,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Download")
|
||||
}
|
||||
}
|
||||
}
|
||||
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = PrimaryBlue,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = if (downloadStatus == DownloadStatus.DECRYPTING) "Decrypting..." else "Downloading...",
|
||||
fontSize = 12.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.7f) else Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
DownloadStatus.ERROR -> {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = Color.Red,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("Download failed", color = Color.Red, fontSize = 12.sp)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextButton(onClick = download) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File attachment
|
||||
*/
|
||||
@Composable
|
||||
fun FileAttachment(
|
||||
attachment: MessageAttachment,
|
||||
chachaKey: String,
|
||||
privateKey: String,
|
||||
isDarkTheme: Boolean
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
|
||||
|
||||
// Парсим метаданные: "filesize::filename"
|
||||
val preview = attachment.preview
|
||||
val parts = preview.split("::")
|
||||
val (fileSize, fileName) = when {
|
||||
parts.size >= 3 -> {
|
||||
// Формат: "UUID::filesize::filename"
|
||||
val size = parts[1].toLongOrNull() ?: 0L
|
||||
val name = parts.drop(2).joinToString("::")
|
||||
Pair(size, name)
|
||||
}
|
||||
parts.size == 2 -> {
|
||||
val size = parts[0].toLongOrNull() ?: 0L
|
||||
Pair(size, parts[1])
|
||||
}
|
||||
else -> Pair(0L, "File")
|
||||
}
|
||||
|
||||
// Проверяем статус
|
||||
LaunchedEffect(attachment.id) {
|
||||
downloadStatus = if (isDownloadTag(preview)) {
|
||||
DownloadStatus.NOT_DOWNLOADED
|
||||
} else {
|
||||
DownloadStatus.DOWNLOADED
|
||||
}
|
||||
}
|
||||
|
||||
val download: () -> Unit = {
|
||||
scope.launch {
|
||||
try {
|
||||
downloadStatus = DownloadStatus.DOWNLOADING
|
||||
|
||||
val tag = getDownloadTag(preview)
|
||||
if (tag.isEmpty()) {
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
return@launch
|
||||
}
|
||||
|
||||
val encryptedContent = TransportManager.downloadFile(attachment.id, tag)
|
||||
|
||||
downloadStatus = DownloadStatus.DECRYPTING
|
||||
|
||||
val decrypted = MessageCrypto.decryptAttachmentBlob(
|
||||
encryptedContent,
|
||||
chachaKey,
|
||||
privateKey
|
||||
)
|
||||
|
||||
if (decrypted != null) {
|
||||
// TODO: Сохранить файл в Downloads
|
||||
downloadStatus = DownloadStatus.DOWNLOADED
|
||||
} else {
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("FileAttachment", "Download failed", e)
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable {
|
||||
if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) {
|
||||
download()
|
||||
}
|
||||
},
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// File icon
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(PrimaryBlue.copy(alpha = 0.2f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = PrimaryBlue,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// File info
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = fileName,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isDarkTheme) Color.White else Color.Black,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = formatFileSize(fileSize),
|
||||
fontSize = 12.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
// Download button/status
|
||||
when (downloadStatus) {
|
||||
DownloadStatus.NOT_DOWNLOADED -> {
|
||||
IconButton(onClick = download) {
|
||||
Icon(
|
||||
Icons.Default.Download,
|
||||
contentDescription = "Download",
|
||||
tint = PrimaryBlue
|
||||
)
|
||||
}
|
||||
}
|
||||
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = PrimaryBlue,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
DownloadStatus.DOWNLOADED -> {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = "Downloaded",
|
||||
tint = Color(0xFF4CAF50)
|
||||
)
|
||||
}
|
||||
DownloadStatus.ERROR -> {
|
||||
IconButton(onClick = download) {
|
||||
Icon(
|
||||
Icons.Default.Refresh,
|
||||
contentDescription = "Retry",
|
||||
tint = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Avatar attachment - аватар отправителя
|
||||
*/
|
||||
@Composable
|
||||
fun AvatarAttachment(
|
||||
attachment: MessageAttachment,
|
||||
chachaKey: String,
|
||||
privateKey: String,
|
||||
senderPublicKey: String,
|
||||
avatarRepository: AvatarRepository?,
|
||||
isDarkTheme: Boolean
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
|
||||
var avatarBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(attachment.id) {
|
||||
downloadStatus = if (attachment.blob.isNotEmpty() && !isDownloadTag(attachment.preview)) {
|
||||
DownloadStatus.DOWNLOADED
|
||||
} else if (isDownloadTag(attachment.preview)) {
|
||||
DownloadStatus.NOT_DOWNLOADED
|
||||
} else {
|
||||
DownloadStatus.DOWNLOADED
|
||||
}
|
||||
|
||||
if (downloadStatus == DownloadStatus.DOWNLOADED && attachment.blob.isNotEmpty()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
avatarBitmap = base64ToBitmap(attachment.blob)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val download: () -> Unit = {
|
||||
scope.launch {
|
||||
try {
|
||||
downloadStatus = DownloadStatus.DOWNLOADING
|
||||
|
||||
val tag = getDownloadTag(attachment.preview)
|
||||
if (tag.isEmpty()) {
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
return@launch
|
||||
}
|
||||
|
||||
val encryptedContent = TransportManager.downloadFile(attachment.id, tag)
|
||||
|
||||
downloadStatus = DownloadStatus.DECRYPTING
|
||||
|
||||
val decrypted = MessageCrypto.decryptAttachmentBlob(
|
||||
encryptedContent,
|
||||
chachaKey,
|
||||
privateKey
|
||||
)
|
||||
|
||||
if (decrypted != null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
avatarBitmap = base64ToBitmap(decrypted)
|
||||
}
|
||||
// Сохраняем аватар в кэш
|
||||
avatarRepository?.saveAvatar(senderPublicKey, decrypted)
|
||||
downloadStatus = DownloadStatus.DOWNLOADED
|
||||
} else {
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("AvatarAttachment", "Download failed", e)
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Avatar preview
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(60.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFD0D0D0)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (downloadStatus) {
|
||||
DownloadStatus.DOWNLOADED -> {
|
||||
avatarBitmap?.let { bitmap ->
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = "Avatar",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = PrimaryBlue,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Icon(
|
||||
Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
tint = if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Avatar",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isDarkTheme) Color.White else Color.Black
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
Icons.Default.Lock,
|
||||
contentDescription = null,
|
||||
tint = PrimaryBlue,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "An avatar image shared in the message.",
|
||||
fontSize = 12.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) {
|
||||
Button(
|
||||
onClick = download,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PrimaryBlue
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Download,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Download", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
private val uuidRegex = Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
||||
|
||||
private fun isDownloadTag(preview: String): Boolean {
|
||||
val firstPart = preview.split("::").firstOrNull() ?: return false
|
||||
return uuidRegex.matches(firstPart)
|
||||
}
|
||||
|
||||
private fun getDownloadTag(preview: String): String {
|
||||
val parts = preview.split("::")
|
||||
if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) {
|
||||
return parts[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private fun base64ToBitmap(base64: String): Bitmap? {
|
||||
return try {
|
||||
val cleanBase64 = if (base64.contains(",")) {
|
||||
base64.substringAfter(",")
|
||||
} else {
|
||||
base64
|
||||
}
|
||||
val bytes = Base64.decode(cleanBase64, Base64.DEFAULT)
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatFileSize(bytes: Long): String {
|
||||
return when {
|
||||
bytes < 1024 -> "$bytes B"
|
||||
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
||||
bytes < 1024 * 1024 * 1024 -> String.format("%.1f MB", bytes / (1024.0 * 1024.0))
|
||||
else -> String.format("%.1f GB", bytes / (1024.0 * 1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,8 @@ fun MessageBubble(
|
||||
isSelected: Boolean = false,
|
||||
isHighlighted: Boolean = false,
|
||||
isSavedMessages: Boolean = false,
|
||||
privateKey: String = "",
|
||||
senderPublicKey: String = "",
|
||||
onLongClick: () -> Unit = {},
|
||||
onClick: () -> Unit = {},
|
||||
onSwipeToReply: () -> Unit = {},
|
||||
@@ -334,6 +336,21 @@ fun MessageBubble(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
// 📎 Attachments (IMAGE, FILE, AVATAR)
|
||||
if (message.attachments.isNotEmpty()) {
|
||||
MessageAttachments(
|
||||
attachments = message.attachments,
|
||||
chachaKey = message.chachaKey,
|
||||
privateKey = privateKey,
|
||||
isOutgoing = message.isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
senderPublicKey = senderPublicKey
|
||||
)
|
||||
if (message.text.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Если есть reply - текст слева, время справа на одной строке
|
||||
if (message.replyData != null) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.rosetta.messenger.ui.chats.models
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.rosetta.messenger.data.Message
|
||||
import com.rosetta.messenger.network.DeliveryStatus
|
||||
import com.rosetta.messenger.network.MessageAttachment
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import java.util.*
|
||||
|
||||
@@ -32,7 +33,9 @@ data class ChatMessage(
|
||||
val timestamp: Date,
|
||||
val status: MessageStatus = MessageStatus.SENT,
|
||||
val showDateHeader: Boolean = false,
|
||||
val replyData: ReplyData? = null
|
||||
val replyData: ReplyData? = null,
|
||||
val attachments: List<MessageAttachment> = emptyList(),
|
||||
val chachaKey: String = "" // Для расшифровки attachments
|
||||
)
|
||||
|
||||
/** Message delivery and read status */
|
||||
|
||||
Reference in New Issue
Block a user