feat: Refactor attachment components to adopt Telegram-style UI for images and files

This commit is contained in:
k1ngsterr1
2026-01-24 15:03:23 +05:00
parent fa40f8a535
commit d5083e60a5
2 changed files with 226 additions and 235 deletions

View File

@@ -90,6 +90,9 @@ dependencies {
implementation("io.coil-kt:coil-compose:2.5.0")
implementation("io.coil-kt:coil-gif:2.5.0") // For animated WebP/GIF support
// Blurhash for image placeholders
implementation("com.vanniktech:blurhash:0.1.0")
// Crypto libraries for key generation
implementation("org.bitcoinj:bitcoinj-core:0.16.2")
implementation("org.bouncycastle:bcprov-jdk15to18:1.77")

View File

@@ -105,7 +105,7 @@ fun MessageAttachments(
}
/**
* Image attachment
* Image attachment - Telegram style
*/
@Composable
fun ImageAttachment(
@@ -114,26 +114,20 @@ fun ImageAttachment(
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)
@@ -145,19 +139,15 @@ fun ImageAttachment(
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,
@@ -179,71 +169,64 @@ fun ImageAttachment(
}
}
Card(
// Telegram-style: Изображение без Card wrapper
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.widthIn(min = 200.dp, max = 280.dp)
.heightIn(min = 150.dp, max = 350.dp)
.clip(RoundedCornerShape(8.dp))
.clickable {
if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) {
download()
}
},
colors = CardDefaults.cardColors(
containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)
)
contentAlignment = Alignment.Center
) {
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
)
}
when (downloadStatus) {
DownloadStatus.DOWNLOADED -> {
imageBitmap?.let { bitmap ->
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "Image",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
DownloadStatus.NOT_DOWNLOADED -> {
// Показываем preview (blurhash) если есть
}
DownloadStatus.NOT_DOWNLOADED -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(16.dp)
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
Icons.Default.Image,
Icons.Default.Download,
contentDescription = null,
tint = if (isDarkTheme) Color.White.copy(alpha = 0.7f) else Color.Gray,
modifier = Modifier.size(48.dp)
tint = PrimaryBlue,
modifier = Modifier.size(40.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")
}
Text(
"Tap to download",
fontSize = 13.sp,
color = if (isDarkTheme) Color.White.copy(0.7f) else Color.Gray
)
}
}
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
}
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(16.dp)
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(
color = PrimaryBlue,
@@ -253,32 +236,44 @@ fun ImageAttachment(
Text(
text = if (downloadStatus == DownloadStatus.DECRYPTING) "Decrypting..." else "Downloading...",
fontSize = 12.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.7f) else Color.Gray
color = if (isDarkTheme) Color.White.copy(0.7f) else Color.Gray
)
}
}
DownloadStatus.ERROR -> {
}
DownloadStatus.ERROR -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(16.dp)
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = Color.Red,
tint = Color(0xFFE53935),
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))
Text("Failed", fontSize = 12.sp, color = Color(0xFFE53935))
Spacer(modifier = Modifier.height(4.dp))
TextButton(onClick = download) {
Text("Retry")
Text("Retry", fontSize = 12.sp, color = PrimaryBlue)
}
}
}
else -> {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
else -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = PrimaryBlue)
}
}
}
@@ -286,7 +281,7 @@ fun ImageAttachment(
}
/**
* File attachment
* File attachment - Telegram style
*/
@Composable
fun FileAttachment(
@@ -298,12 +293,10 @@ fun FileAttachment(
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)
@@ -315,7 +308,6 @@ fun FileAttachment(
else -> Pair(0L, "File")
}
// Проверяем статус
LaunchedEffect(attachment.id) {
downloadStatus = if (isDownloadTag(preview)) {
DownloadStatus.NOT_DOWNLOADED
@@ -328,7 +320,6 @@ fun FileAttachment(
scope.launch {
try {
downloadStatus = DownloadStatus.DOWNLOADING
val tag = getDownloadTag(preview)
if (tag.isEmpty()) {
downloadStatus = DownloadStatus.ERROR
@@ -336,7 +327,6 @@ fun FileAttachment(
}
val encryptedContent = TransportManager.downloadFile(attachment.id, tag)
downloadStatus = DownloadStatus.DECRYPTING
val decrypted = MessageCrypto.decryptAttachmentBlob(
@@ -346,7 +336,6 @@ fun FileAttachment(
)
if (decrypted != null) {
// TODO: Сохранить файл в Downloads
downloadStatus = DownloadStatus.DOWNLOADED
} else {
downloadStatus = DownloadStatus.ERROR
@@ -358,102 +347,97 @@ fun FileAttachment(
}
}
Card(
// Telegram-style: компактный row с иконкой файла
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clip(RoundedCornerShape(8.dp))
.background(if (isDarkTheme) Color(0xFF1F1F1F) else Color.White.copy(0.3f))
.clickable {
if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) {
download()
}
},
colors = CardDefaults.cardColors(
containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)
)
}
.padding(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Row(
// File icon
Box(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
.size(44.dp)
.clip(RoundedCornerShape(6.dp))
.background(PrimaryBlue.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center
) {
// 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(22.dp)
)
}
Spacer(modifier = Modifier.width(10.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.5f) else Color.Gray
)
}
Spacer(modifier = Modifier.width(8.dp))
// Download/status icon
when (downloadStatus) {
DownloadStatus.NOT_DOWNLOADED -> {
Icon(
Icons.Default.InsertDriveFile,
contentDescription = null,
Icons.Default.Download,
contentDescription = "Download",
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
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = PrimaryBlue,
strokeWidth = 2.dp
)
}
// 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 -> {}
DownloadStatus.DOWNLOADED -> {
Icon(
Icons.Default.CheckCircle,
contentDescription = "Downloaded",
tint = Color(0xFF4CAF50),
modifier = Modifier.size(24.dp)
)
}
DownloadStatus.ERROR -> {
Icon(
Icons.Default.Refresh,
contentDescription = "Retry",
tint = Color(0xFFE53935),
modifier = Modifier.size(24.dp)
)
}
else -> {}
}
}
}
/**
* Avatar attachment - аватар отправителя
* Avatar attachment - Telegram style (более компактный)
*/
@Composable
fun AvatarAttachment(
@@ -464,7 +448,6 @@ fun AvatarAttachment(
avatarRepository: AvatarRepository?,
isDarkTheme: Boolean
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
@@ -490,7 +473,6 @@ fun AvatarAttachment(
scope.launch {
try {
downloadStatus = DownloadStatus.DOWNLOADING
val tag = getDownloadTag(attachment.preview)
if (tag.isEmpty()) {
downloadStatus = DownloadStatus.ERROR
@@ -498,7 +480,6 @@ fun AvatarAttachment(
}
val encryptedContent = TransportManager.downloadFile(attachment.id, tag)
downloadStatus = DownloadStatus.DECRYPTING
val decrypted = MessageCrypto.decryptAttachmentBlob(
@@ -511,7 +492,6 @@ fun AvatarAttachment(
withContext(Dispatchers.IO) {
avatarBitmap = base64ToBitmap(decrypted)
}
// Сохраняем аватар в кэш
avatarRepository?.saveAvatar(senderPublicKey, decrypted)
downloadStatus = DownloadStatus.DOWNLOADED
} else {
@@ -524,99 +504,107 @@ fun AvatarAttachment(
}
}
Card(
// Telegram-style: компактный row с круглым превью
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)),
colors = CardDefaults.cardColors(
containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)
)
.clip(RoundedCornerShape(8.dp))
.background(if (isDarkTheme) Color(0xFF1F1F1F) else Color.White.copy(0.3f))
.clickable(enabled = downloadStatus == DownloadStatus.NOT_DOWNLOADED) {
download()
}
.padding(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Row(
// Avatar preview (круглое)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
.size(50.dp)
.clip(CircleShape)
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)),
contentAlignment = Alignment.Center
) {
// 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)
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(20.dp),
color = PrimaryBlue,
strokeWidth = 2.dp
)
}
else -> {
Icon(
Icons.Default.Person,
contentDescription = null,
tint = if (isDarkTheme) Color.White.copy(0.4f) else Color.Gray,
modifier = Modifier.size(28.dp)
)
}
}
}
Spacer(modifier = Modifier.width(12.dp))
Spacer(modifier = Modifier.width(10.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)
)
}
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "An avatar image shared in the message.",
fontSize = 12.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Gray
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 = if (isDarkTheme) Color.White.copy(0.5f) else Color.Gray,
modifier = Modifier.size(12.dp)
)
}
Text(
text = "Profile photo shared",
fontSize = 12.sp,
color = if (isDarkTheme) Color.White.copy(0.5f) 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)
}
// Download/status icon
when (downloadStatus) {
DownloadStatus.NOT_DOWNLOADED -> {
Icon(
Icons.Default.Download,
contentDescription = "Download",
tint = PrimaryBlue,
modifier = Modifier.size(24.dp)
)
}
DownloadStatus.DOWNLOADED -> {
Icon(
Icons.Default.CheckCircle,
contentDescription = "Downloaded",
tint = Color(0xFF4CAF50),
modifier = Modifier.size(24.dp)
)
}
DownloadStatus.ERROR -> {
Icon(
Icons.Default.Refresh,
contentDescription = "Retry",
tint = Color(0xFFE53935),
modifier = Modifier.size(24.dp)
)
}
else -> {}
}
}
}