feat: Implement file and avatar attachment handling in chat messages
This commit is contained in:
@@ -515,3 +515,24 @@ class PacketPushNotification : Packet() {
|
|||||||
return stream
|
return stream
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request Transport packet (ID: 0x0F)
|
||||||
|
* Запрос адреса транспортного сервера для загрузки/скачивания файлов
|
||||||
|
*/
|
||||||
|
class PacketRequestTransport : Packet() {
|
||||||
|
var transportServer: String = ""
|
||||||
|
|
||||||
|
override fun getPacketId(): Int = 0x0F
|
||||||
|
|
||||||
|
override fun receive(stream: Stream) {
|
||||||
|
transportServer = stream.readString()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun send(): Stream {
|
||||||
|
val stream = Stream()
|
||||||
|
stream.writeInt16(getPacketId())
|
||||||
|
stream.writeString(transportServer)
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -174,6 +174,13 @@ object ProtocolManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🚀 Обработчик транспортного сервера (0x0F)
|
||||||
|
waitPacket(0x0F) { packet ->
|
||||||
|
val transportPacket = packet as PacketRequestTransport
|
||||||
|
android.util.Log.d("Protocol", "🚀 Transport server: ${transportPacket.transportServer}")
|
||||||
|
TransportManager.setTransportServer(transportPacket.transportServer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,6 +217,12 @@ object ProtocolManager {
|
|||||||
*/
|
*/
|
||||||
fun authenticate(publicKey: String, privateHash: String) {
|
fun authenticate(publicKey: String, privateHash: String) {
|
||||||
getProtocol().startHandshake(publicKey, privateHash)
|
getProtocol().startHandshake(publicKey, privateHash)
|
||||||
|
|
||||||
|
// 🚀 Запрашиваем транспортный сервер после авторизации
|
||||||
|
scope.launch {
|
||||||
|
delay(500) // Даём время на завершение handshake
|
||||||
|
TransportManager.requestTransportServer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.*
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние загрузки/скачивания файла
|
||||||
|
*/
|
||||||
|
data class TransportState(
|
||||||
|
val id: String,
|
||||||
|
val progress: Int // 0-100
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Менеджер транспорта файлов
|
||||||
|
* Загрузка и скачивание файлов с транспортного сервера
|
||||||
|
* Совместимо с desktop версией (TransportProvider)
|
||||||
|
*/
|
||||||
|
object TransportManager {
|
||||||
|
private const val TAG = "TransportManager"
|
||||||
|
|
||||||
|
private var transportServer: String? = null
|
||||||
|
|
||||||
|
private val _uploading = MutableStateFlow<List<TransportState>>(emptyList())
|
||||||
|
val uploading: StateFlow<List<TransportState>> = _uploading.asStateFlow()
|
||||||
|
|
||||||
|
private val _downloading = MutableStateFlow<List<TransportState>>(emptyList())
|
||||||
|
val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow()
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Установить адрес транспортного сервера
|
||||||
|
*/
|
||||||
|
fun setTransportServer(server: String) {
|
||||||
|
transportServer = server
|
||||||
|
Log.d(TAG, "🚀 Transport server set: $server")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить адрес транспортного сервера
|
||||||
|
*/
|
||||||
|
fun getTransportServer(): String? = transportServer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запросить адрес транспортного сервера с сервера протокола
|
||||||
|
*/
|
||||||
|
fun requestTransportServer() {
|
||||||
|
val packet = PacketRequestTransport()
|
||||||
|
ProtocolManager.sendPacket(packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузить файл на транспортный сервер
|
||||||
|
* @param id Уникальный ID файла
|
||||||
|
* @param content Содержимое файла (base64 или бинарные данные)
|
||||||
|
* @return Tag для скачивания файла
|
||||||
|
*/
|
||||||
|
suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) {
|
||||||
|
val server = transportServer
|
||||||
|
?: throw IllegalStateException("Transport server is not set")
|
||||||
|
|
||||||
|
Log.d(TAG, "📤 Uploading file: $id")
|
||||||
|
|
||||||
|
// Добавляем в список загрузок
|
||||||
|
_uploading.value = _uploading.value + TransportState(id, 0)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val requestBody = MultipartBody.Builder()
|
||||||
|
.setType(MultipartBody.FORM)
|
||||||
|
.addFormDataPart(
|
||||||
|
"file",
|
||||||
|
id,
|
||||||
|
content.toRequestBody("application/octet-stream".toMediaType())
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$server/u")
|
||||||
|
.post(requestBody)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val response = suspendCoroutine<Response> { cont ->
|
||||||
|
client.newCall(request).enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
cont.resumeWithException(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
cont.resume(response)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw IOException("Upload failed: ${response.code}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val responseBody = response.body?.string()
|
||||||
|
?: throw IOException("Empty response")
|
||||||
|
|
||||||
|
// Parse JSON response to get tag
|
||||||
|
val tag = org.json.JSONObject(responseBody).getString("t")
|
||||||
|
|
||||||
|
Log.d(TAG, "✅ Upload complete: $id -> $tag")
|
||||||
|
|
||||||
|
// Обновляем прогресс до 100%
|
||||||
|
_uploading.value = _uploading.value.map {
|
||||||
|
if (it.id == id) it.copy(progress = 100) else it
|
||||||
|
}
|
||||||
|
|
||||||
|
tag
|
||||||
|
} finally {
|
||||||
|
// Удаляем из списка загрузок
|
||||||
|
_uploading.value = _uploading.value.filter { it.id != id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скачать файл с транспортного сервера
|
||||||
|
* @param id Уникальный ID файла (для трекинга)
|
||||||
|
* @param tag Tag файла на сервере
|
||||||
|
* @return Содержимое файла
|
||||||
|
*/
|
||||||
|
suspend fun downloadFile(id: String, tag: String): String = withContext(Dispatchers.IO) {
|
||||||
|
val server = transportServer
|
||||||
|
?: throw IllegalStateException("Transport server is not set")
|
||||||
|
|
||||||
|
Log.d(TAG, "📥 Downloading file: $id (tag: $tag)")
|
||||||
|
|
||||||
|
// Добавляем в список скачиваний
|
||||||
|
_downloading.value = _downloading.value + TransportState(id, 0)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$server/d/$tag")
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val response = suspendCoroutine<Response> { cont ->
|
||||||
|
client.newCall(request).enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
cont.resumeWithException(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
cont.resume(response)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw IOException("Download failed: ${response.code}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val content = response.body?.string()
|
||||||
|
?: throw IOException("Empty response")
|
||||||
|
|
||||||
|
Log.d(TAG, "✅ Download complete: $id (${content.length} bytes)")
|
||||||
|
|
||||||
|
// Обновляем прогресс до 100%
|
||||||
|
_downloading.value = _downloading.value.map {
|
||||||
|
if (it.id == id) it.copy(progress = 100) else it
|
||||||
|
}
|
||||||
|
|
||||||
|
content
|
||||||
|
} finally {
|
||||||
|
// Удаляем из списка скачиваний
|
||||||
|
_downloading.value = _downloading.value.filter { it.id != id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить прогресс скачивания файла
|
||||||
|
*/
|
||||||
|
fun getDownloadProgress(id: String): Int {
|
||||||
|
return _downloading.value.find { it.id == id }?.progress ?: -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить прогресс загрузки файла
|
||||||
|
*/
|
||||||
|
fun getUploadProgress(id: String): Int {
|
||||||
|
return _uploading.value.find { it.id == id }?.progress ?: -1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1612,6 +1612,10 @@ fun ChatDetailScreen(
|
|||||||
message.id,
|
message.id,
|
||||||
isSavedMessages =
|
isSavedMessages =
|
||||||
isSavedMessages,
|
isSavedMessages,
|
||||||
|
privateKey =
|
||||||
|
currentUserPrivateKey,
|
||||||
|
senderPublicKey =
|
||||||
|
if (message.isOutgoing) currentUserPublicKey else user.publicKey,
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
if (!isSelectionMode
|
if (!isSelectionMode
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -719,6 +719,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
} else {
|
} else {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Парсим все attachments (IMAGE, FILE, AVATAR)
|
||||||
|
val parsedAttachments = parseAllAttachments(entity.attachments)
|
||||||
|
|
||||||
return ChatMessage(
|
return ChatMessage(
|
||||||
id = entity.messageId,
|
id = entity.messageId,
|
||||||
@@ -732,10 +734,50 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
3 -> MessageStatus.READ
|
3 -> MessageStatus.READ
|
||||||
else -> MessageStatus.SENT
|
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 из текста сообщения (fallback формат)
|
||||||
* Формат: "🇵 Reply: "текст цитаты"\n\nосновной текст"
|
* Формат: "🇵 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,
|
isSelected: Boolean = false,
|
||||||
isHighlighted: Boolean = false,
|
isHighlighted: Boolean = false,
|
||||||
isSavedMessages: Boolean = false,
|
isSavedMessages: Boolean = false,
|
||||||
|
privateKey: String = "",
|
||||||
|
senderPublicKey: String = "",
|
||||||
onLongClick: () -> Unit = {},
|
onLongClick: () -> Unit = {},
|
||||||
onClick: () -> Unit = {},
|
onClick: () -> Unit = {},
|
||||||
onSwipeToReply: () -> Unit = {},
|
onSwipeToReply: () -> Unit = {},
|
||||||
@@ -334,6 +336,21 @@ fun MessageBubble(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
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 - текст слева, время справа на одной строке
|
// Если есть reply - текст слева, время справа на одной строке
|
||||||
if (message.replyData != null) {
|
if (message.replyData != null) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.rosetta.messenger.ui.chats.models
|
|||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import com.rosetta.messenger.data.Message
|
import com.rosetta.messenger.data.Message
|
||||||
import com.rosetta.messenger.network.DeliveryStatus
|
import com.rosetta.messenger.network.DeliveryStatus
|
||||||
|
import com.rosetta.messenger.network.MessageAttachment
|
||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@@ -32,7 +33,9 @@ data class ChatMessage(
|
|||||||
val timestamp: Date,
|
val timestamp: Date,
|
||||||
val status: MessageStatus = MessageStatus.SENT,
|
val status: MessageStatus = MessageStatus.SENT,
|
||||||
val showDateHeader: Boolean = false,
|
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 */
|
/** Message delivery and read status */
|
||||||
|
|||||||
Reference in New Issue
Block a user