Refactor image blurring to use RenderScript for improved performance and quality

- Replaced custom fast blur implementation with RenderScript-based Gaussian blur in BlurredAvatarBackground and AppearanceScreen.
- Updated image processing logic to scale down bitmaps before applying blur for efficiency.
- Simplified blur logic by removing unnecessary pixel manipulation methods.
- Enhanced media preview handling in OtherProfileScreen to utilize new Gaussian blur function.
- Improved code readability and maintainability by consolidating blur functionality.
This commit is contained in:
2026-02-22 12:32:19 +05:00
parent 5b9b3f83f7
commit ba7182abe6
13 changed files with 1378 additions and 697 deletions

View File

@@ -15,6 +15,9 @@ import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -169,6 +172,16 @@ fun ChatDetailScreen(
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
val isSelectionMode = selectedMessages.isNotEmpty()
// 💬 MESSAGE CONTEXT MENU STATE
var contextMenuMessage by remember { mutableStateOf<ChatMessage?>(null) }
var showContextMenu by remember { mutableStateOf(false) }
var contextMenuIsPinned by remember { mutableStateOf(false) }
// 📌 PINNED MESSAGES
val pinnedMessages by viewModel.pinnedMessages.collectAsState()
val currentPinnedIndex by viewModel.currentPinnedIndex.collectAsState()
var isPinnedBannerDismissed by remember { mutableStateOf(false) }
// Логирование изменений selection mode
LaunchedEffect(isSelectionMode, selectedMessages.size) {}
@@ -463,7 +476,24 @@ fun ChatDetailScreen(
}
}
// 🔥 messagesWithDates — pre-computed in ViewModel on Dispatchers.Default
// <EFBFBD> Текст текущего pinned сообщения для баннера
val currentPinnedMessagePreview = remember(pinnedMessages, currentPinnedIndex, messages) {
if (pinnedMessages.isEmpty()) ""
else {
val idx = currentPinnedIndex.coerceIn(0, pinnedMessages.size - 1)
val pinnedMsgId = pinnedMessages[idx].messageId
messages.find { it.id == pinnedMsgId }?.text ?: "..."
}
}
// 📌 Сброс dismissed при изменении pinned messages (когда добавляют новый pin)
LaunchedEffect(pinnedMessages.size) {
if (pinnedMessages.isNotEmpty()) {
isPinnedBannerDismissed = false
}
}
// <20>🔥 messagesWithDates — pre-computed in ViewModel on Dispatchers.Default
// (dedup + sort + date headers off the main thread)
val messagesWithDates by viewModel.messagesWithDates.collectAsState()
@@ -603,6 +633,7 @@ fun ChatDetailScreen(
Scaffold(
contentWindowInsets = WindowInsets(0.dp),
topBar = {
Column {
// 🔥 UNIFIED HEADER - один контейнер, контент меняется внутри
Box(
modifier =
@@ -1237,6 +1268,42 @@ fun ChatDetailScreen(
)
)
} // Закрытие Box unified header
// 📌 PINNED MESSAGE BANNER with shrink animation
androidx.compose.animation.AnimatedVisibility(
visible = pinnedMessages.isNotEmpty() && !isPinnedBannerDismissed,
enter = expandVertically(
animationSpec = tween(250, easing = androidx.compose.animation.core.FastOutSlowInEasing)
) + fadeIn(tween(200)),
exit = shrinkVertically(
animationSpec = tween(250, easing = androidx.compose.animation.core.FastOutSlowInEasing)
) + fadeOut(tween(150))
) {
val idx = currentPinnedIndex.coerceIn(0, (pinnedMessages.size - 1).coerceAtLeast(0))
PinnedMessageBanner(
pinnedCount = pinnedMessages.size.coerceAtLeast(1),
currentIndex = idx,
messagePreview = currentPinnedMessagePreview,
isDarkTheme = isDarkTheme,
onBannerClick = {
if (pinnedMessages.isNotEmpty()) {
val messageId = viewModel.navigateToNextPinned()
if (messageId != null) {
scrollToMessage(messageId)
}
}
},
onCloseClick = {
if (pinnedMessages.isNotEmpty()) {
// 📌 Открепляем текущий показанный пин
val pinIdx = currentPinnedIndex.coerceIn(0, pinnedMessages.size - 1)
val pinToRemove = pinnedMessages[pinIdx]
viewModel.unpinMessage(pinToRemove.messageId)
}
}
)
}
} // Закрытие Column topBar
},
containerColor = backgroundColor, // Фон всего чата
// 🔥 Bottom bar - инпут с умным padding
@@ -2008,26 +2075,24 @@ fun ChatDetailScreen(
.LongPress
)
if (!isSelectionMode
) {
val imm =
context.getSystemService(
Context.INPUT_METHOD_SERVICE
) as
InputMethodManager
imm.hideSoftInputFromWindow(
view.windowToken,
0
)
focusManager
.clearFocus()
showEmojiPicker =
false
}
toggleMessageSelection(
selectionKey,
true
)
// <20> Long press = selection mode
val imm =
context.getSystemService(
Context.INPUT_METHOD_SERVICE
) as
InputMethodManager
imm.hideSoftInputFromWindow(
view.windowToken,
0
)
focusManager
.clearFocus()
showEmojiPicker =
false
toggleMessageSelection(
selectionKey,
true
)
},
onClick = {
val hasAvatar =
@@ -2037,11 +2102,24 @@ fun ChatDetailScreen(
AttachmentType
.AVATAR
}
val isPhotoOnly =
message.attachments.isNotEmpty() &&
message.text.isEmpty() &&
message.attachments.all {
it.type == AttachmentType.IMAGE
}
if (isSelectionMode) {
toggleMessageSelection(
selectionKey,
!hasAvatar
)
} else if (!hasAvatar && !isPhotoOnly) {
// 💬 Tap = context menu
contextMenuMessage = message
showContextMenu = true
scope.launch {
contextMenuIsPinned = viewModel.isMessagePinned(message.id)
}
}
},
onSwipeToReply = {
@@ -2122,9 +2200,91 @@ fun ChatDetailScreen(
onUserProfileClick(resolvedUser)
}
}
}
)
}
},
contextMenuContent = {
// 💬 Context menu anchored to this bubble
if (showContextMenu && contextMenuMessage?.id == message.id) {
val msg = contextMenuMessage!!
MessageContextMenu(
expanded = true,
onDismiss = {
showContextMenu = false
contextMenuMessage = null
},
isDarkTheme = isDarkTheme,
isPinned = contextMenuIsPinned,
isOutgoing = msg.isOutgoing,
hasText = msg.text.isNotBlank(),
isSystemAccount = isSystemAccount,
onReply = {
viewModel.setReplyMessages(listOf(msg))
showContextMenu = false
contextMenuMessage = null
},
onCopy = {
clipboardManager.setText(
androidx.compose.ui.text.AnnotatedString(msg.text)
)
showContextMenu = false
contextMenuMessage = null
},
onForward = {
val forwardMessages = if (msg.forwardedMessages.isNotEmpty()) {
msg.forwardedMessages.map { fwd ->
ForwardManager.ForwardMessage(
messageId = fwd.messageId,
text = fwd.text,
timestamp = msg.timestamp.time,
isOutgoing = fwd.isFromMe,
senderPublicKey = fwd.senderPublicKey.ifEmpty {
if (fwd.isFromMe) currentUserPublicKey else user.publicKey
},
originalChatPublicKey = user.publicKey,
senderName = fwd.forwardedFromName.ifEmpty { fwd.senderName.ifEmpty { "User" } },
attachments = fwd.attachments
.filter { it.type != AttachmentType.MESSAGES }
.map { it.copy(localUri = "") }
)
}
} else {
listOf(ForwardManager.ForwardMessage(
messageId = msg.id,
text = msg.text,
timestamp = msg.timestamp.time,
isOutgoing = msg.isOutgoing,
senderPublicKey = if (msg.isOutgoing) currentUserPublicKey else user.publicKey,
originalChatPublicKey = user.publicKey,
senderName = if (msg.isOutgoing) currentUserName.ifEmpty { "You" }
else user.title.ifEmpty { user.username.ifEmpty { "User" } },
attachments = msg.attachments
.filter { it.type != AttachmentType.MESSAGES }
.map { it.copy(localUri = "") }
))
}
ForwardManager.setForwardMessages(forwardMessages, showPicker = false)
showForwardPicker = true
showContextMenu = false
contextMenuMessage = null
},
onPin = {
if (contextMenuIsPinned) {
viewModel.unpinMessage(msg.id)
} else {
viewModel.pinMessage(msg.id)
isPinnedBannerDismissed = false
}
showContextMenu = false
contextMenuMessage = null
},
onDelete = {
viewModel.deleteMessage(msg.id)
showContextMenu = false
contextMenuMessage = null
}
)
}
} // contextMenuContent
)
}
}
}
@@ -2547,4 +2707,7 @@ fun ChatDetailScreen(
onClearLogs = { ProtocolManager.clearLogs() }
)
}
}
}

View File

@@ -96,6 +96,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val database = RosettaDatabase.getDatabase(application)
private val dialogDao = database.dialogDao()
private val messageDao = database.messageDao()
private val pinnedMessageDao = database.pinnedMessageDao()
// MessageRepository для подписки на события новых сообщений
private val messageRepository =
@@ -190,6 +191,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val _isForwardMode = MutableStateFlow(false)
val isForwardMode: StateFlow<Boolean> = _isForwardMode.asStateFlow()
// 📌 Pinned messages state
private val _pinnedMessages = MutableStateFlow<List<com.rosetta.messenger.database.PinnedMessageEntity>>(emptyList())
val pinnedMessages: StateFlow<List<com.rosetta.messenger.database.PinnedMessageEntity>> = _pinnedMessages.asStateFlow()
private val _currentPinnedIndex = MutableStateFlow(0)
val currentPinnedIndex: StateFlow<Int> = _currentPinnedIndex.asStateFlow()
private var pinnedCollectionJob: Job? = null
// Пагинация
private var currentOffset = 0
private var hasMoreMessages = true
@@ -603,6 +613,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Подписываемся на онлайн статус
subscribeToOnlineStatus()
// 📌 Подписываемся на pinned messages
pinnedCollectionJob?.cancel()
pinnedCollectionJob = viewModelScope.launch(Dispatchers.IO) {
val acc = myPublicKey ?: return@launch
val dialogKey = getDialogKey(acc, publicKey)
pinnedMessageDao.getPinnedMessages(acc, dialogKey).collect { pins ->
_pinnedMessages.value = pins
// Всегда показываем самый последний пин (index 0, ORDER BY DESC)
_currentPinnedIndex.value = 0
}
}
// <20> P1.2: Загружаем сообщения СРАЗУ — параллельно с анимацией SwipeBackContainer
loadMessagesFromDatabase(delayMs = 0L)
}
@@ -1660,14 +1682,74 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
_isForwardMode.value = false
}
// ═══════════════════════════════════════════════════════════
// 📌 PINNED MESSAGES
// ═══════════════════════════════════════════════════════════
/** 📌 Закрепить сообщение */
fun pinMessage(messageId: String) {
viewModelScope.launch(Dispatchers.IO) {
val account = myPublicKey ?: return@launch
val opponent = opponentKey ?: return@launch
val dialogKey = getDialogKey(account, opponent)
pinnedMessageDao.insertPin(
com.rosetta.messenger.database.PinnedMessageEntity(
account = account,
dialogKey = dialogKey,
messageId = messageId
)
)
}
}
/** 📌 Открепить сообщение */
fun unpinMessage(messageId: String) {
viewModelScope.launch(Dispatchers.IO) {
val account = myPublicKey ?: return@launch
val opponent = opponentKey ?: return@launch
val dialogKey = getDialogKey(account, opponent)
pinnedMessageDao.removePin(account, dialogKey, messageId)
}
}
/** 📌 Проверить, закреплено ли сообщение */
suspend fun isMessagePinned(messageId: String): Boolean {
val account = myPublicKey ?: return false
val opponent = opponentKey ?: return false
val dialogKey = getDialogKey(account, opponent)
return pinnedMessageDao.isPinned(account, dialogKey, messageId)
}
/** 📌 Перейти к следующему закреплённому сообщению (от нового к старому, циклически) */
fun navigateToNextPinned(): String? {
val pins = _pinnedMessages.value
if (pins.isEmpty()) return null
val currentIdx = _currentPinnedIndex.value
val nextIdx = (currentIdx + 1) % pins.size
_currentPinnedIndex.value = nextIdx
return pins[nextIdx].messageId
}
/** 📌 Открепить все сообщения */
fun unpinAllMessages() {
viewModelScope.launch(Dispatchers.IO) {
val account = myPublicKey ?: return@launch
val opponent = opponentKey ?: return@launch
val dialogKey = getDialogKey(account, opponent)
pinnedMessageDao.unpinAll(account, dialogKey)
}
}
/** 🔥 Удалить сообщение (для ошибки отправки) */
fun deleteMessage(messageId: String) {
// Удаляем из UI сразу на main
_messages.value = _messages.value.filter { it.id != messageId }
// Удаляем из БД в IO
// Удаляем из БД в IO + удаляем pin если был
viewModelScope.launch(Dispatchers.IO) {
val account = myPublicKey ?: return@launch
val dialogKey = opponentKey ?: return@launch
pinnedMessageDao.removePin(account, dialogKey, messageId)
messageDao.deleteMessage(account, messageId)
}
}

View File

@@ -42,6 +42,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -3645,67 +3646,72 @@ fun DialogItemContent(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// 🔥 Показываем typing индикатор или последнее сообщение
if (isTyping) {
TypingIndicatorSmall()
} else if (!dialog.draftText.isNullOrEmpty()) {
// 📝 Показываем черновик (как в Telegram)
Row(modifier = Modifier.weight(1f)) {
Text(
text = "Draft: ",
fontSize = 14.sp,
color = Color(0xFFFF3B30), // Красный как в Telegram
fontWeight = FontWeight.Normal,
maxLines = 1
)
AppleEmojiText(
text = dialog.draftText,
modifier = Modifier.weight(1f),
fontSize = 14.sp,
color = secondaryTextColor,
fontWeight = FontWeight.Normal,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false
)
}
} else {
// 📎 Определяем что показывать - attachment или текст
val displayText =
when {
dialog.lastMessageAttachmentType ==
"Photo" -> "Photo"
dialog.lastMessageAttachmentType ==
"File" -> "File"
dialog.lastMessageAttachmentType ==
"Avatar" -> "Avatar"
dialog.lastMessageAttachmentType ==
"Forwarded" -> "Forwarded message"
dialog.lastMessage.isEmpty() ->
"No messages"
else -> dialog.lastMessage
}
// Stable weighted box prevents layout jitter on typing transition
Box(
modifier = Modifier.weight(1f).heightIn(min = 20.dp),
contentAlignment = Alignment.CenterStart
) {
Crossfade(
targetState = isTyping,
animationSpec = tween(150),
label = "chatSubtitle"
) { showTyping ->
if (showTyping) {
TypingIndicatorSmall()
} else if (!dialog.draftText.isNullOrEmpty()) {
Row {
Text(
text = "Draft: ",
fontSize = 14.sp,
color = Color(0xFFFF3B30),
fontWeight = FontWeight.Normal,
maxLines = 1
)
AppleEmojiText(
text = dialog.draftText,
modifier = Modifier.weight(1f),
fontSize = 14.sp,
color = secondaryTextColor,
fontWeight = FontWeight.Normal,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false
)
}
} else {
val displayText =
when {
dialog.lastMessageAttachmentType ==
"Photo" -> "Photo"
dialog.lastMessageAttachmentType ==
"File" -> "File"
dialog.lastMessageAttachmentType ==
"Avatar" -> "Avatar"
dialog.lastMessageAttachmentType ==
"Forwarded" -> "Forwarded message"
dialog.lastMessage.isEmpty() ->
"No messages"
else -> dialog.lastMessage
}
// 🔥 Используем AppleEmojiText для отображения эмодзи
// Если есть непрочитанные - текст темнее
AppleEmojiText(
text = displayText,
fontSize = 14.sp,
color =
if (dialog.unreadCount > 0)
textColor.copy(alpha = 0.85f)
else secondaryTextColor,
fontWeight =
if (dialog.unreadCount > 0)
FontWeight.Medium
else FontWeight.Normal,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
modifier = Modifier.weight(1f),
enableLinks =
false // 🔗 Ссылки не кликабельны в списке
// чатов
)
AppleEmojiText(
text = displayText,
fontSize = 14.sp,
color =
if (dialog.unreadCount > 0)
textColor.copy(alpha = 0.85f)
else secondaryTextColor,
fontWeight =
if (dialog.unreadCount > 0)
FontWeight.Medium
else FontWeight.Normal,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
modifier = Modifier.fillMaxWidth(),
enableLinks = false
)
}
}
}
// Unread badge
@@ -3778,50 +3784,61 @@ fun DialogItemContent(
}
/**
* 🔥 Компактный индикатор typing для списка чатов Голубой текст "typing" с анимированными точками
* Telegram-style typing indicator for chat list — 3 bouncing Canvas circles
* with sequential wave animation (scale + vertical offset + opacity).
*/
@Composable
fun TypingIndicatorSmall() {
val infiniteTransition = rememberInfiniteTransition(label = "typing")
val typingColor = PrimaryBlue
val infiniteTransition = rememberInfiniteTransition(label = "typing")
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(1.dp)
) {
// Each dot animates 0→1→0 in a 1200 ms cycle, staggered by 150 ms
val dotProgresses = List(3) { index ->
infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1200
0f at 0 with LinearEasing
1f at 300 with FastOutSlowInEasing
0f at 600 with FastOutSlowInEasing
0f at 1200 with LinearEasing
},
repeatMode = RepeatMode.Restart,
initialStartOffset = StartOffset(index * 150)
),
label = "dot$index"
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "typing",
fontSize = 14.sp,
color = typingColor,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.width(2.dp))
// 3 анимированные точки
repeat(3) { index ->
val offsetY by
infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = -3f,
animationSpec =
infiniteRepeatable(
animation =
tween(
durationMillis = 500,
delayMillis = index * 120,
easing = FastOutSlowInEasing
),
repeatMode = RepeatMode.Reverse
),
label = "dot$index"
// Fixed-size canvas — big enough for bounce, never changes layout
Canvas(modifier = Modifier.size(width = 18.dp, height = 14.dp)) {
val dotRadius = 1.5.dp.toPx()
val dotSpacing = 2.5.dp.toPx()
val maxBounce = 2.dp.toPx()
val centerY = size.height / 2f + 1.dp.toPx()
for (i in 0..2) {
val p = dotProgresses[i].value
val bounce = kotlin.math.sin(p * Math.PI).toFloat()
val cx = dotRadius + i * (dotRadius * 2 + dotSpacing)
val cy = centerY - bounce * maxBounce
val alpha = 0.4f + bounce * 0.6f
drawCircle(
color = typingColor.copy(alpha = alpha),
radius = dotRadius,
center = Offset(cx, cy)
)
Text(
text = ".",
fontSize = 14.sp,
color = typingColor,
fontWeight = FontWeight.Medium,
modifier = Modifier.offset(y = offsetY.dp)
)
}
}
}
}

View File

@@ -62,6 +62,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.content.FileProvider
@@ -223,6 +224,14 @@ object ImageBitmapCache {
}
}
/**
* 🔒 Global semaphore to limit concurrent image decode/download operations
* This prevents lag when opening collages with many photos
*/
object ImageLoadSemaphore {
val semaphore = kotlinx.coroutines.sync.Semaphore(3)
}
/**
* 📐 Telegram Bubble Specification
* Все константы взяты из ChatMessageCell.java и Theme.java
@@ -662,15 +671,30 @@ fun ImageCollage(
}
// Остальные по 3 в ряд
val remaining = attachments.drop(2)
remaining.chunked(3).forEachIndexed { rowIndex, rowItems ->
val isLastRow = rowIndex == remaining.chunked(3).size - 1
val rows = remaining.chunked(3)
val totalRows = rows.size
rows.forEachIndexed { rowIndex, rowItems ->
val isLastRow = rowIndex == totalRows - 1
val isIncompleteRow = rowItems.size < 3
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing)
horizontalArrangement = if (isIncompleteRow)
Arrangement.Start
else
Arrangement.spacedBy(spacing)
) {
rowItems.forEachIndexed { index, attachment ->
val isLastItem = isLastRow && index == rowItems.size - 1
Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
// Для неполных рядов используем фиксированную ширину = 1/3 от общей
val cellModifier = if (isIncompleteRow) {
Modifier
.fillMaxWidth(1f / 3f)
.padding(end = if (index < rowItems.size - 1) spacing else 0.dp)
.aspectRatio(1f)
} else {
Modifier.weight(1f).aspectRatio(1f)
}
Box(modifier = cellModifier) {
ImageAttachment(
attachment = attachment,
chachaKey = chachaKey,
@@ -688,8 +712,6 @@ fun ImageCollage(
)
}
}
// Заполняем пустые места если в ряду меньше 3 фото
repeat(3 - rowItems.size) { Spacer(modifier = Modifier.weight(1f)) }
}
}
}
@@ -825,9 +847,11 @@ fun ImageAttachment(
// Загружаем изображение если статус DOWNLOADED
if (downloadStatus == DownloadStatus.DOWNLOADED) {
withContext(Dispatchers.IO) {
// 🚀 0. Если есть localUri - загружаем напрямую из URI (optimistic UI)
if (imageBitmap == null && attachment.localUri.isNotEmpty()) {
// 🔒 Ограничиваем параллельные загрузки через семафор
ImageLoadSemaphore.semaphore.withPermit {
withContext(Dispatchers.IO) {
// 🚀 0. Если есть localUri - загружаем напрямую из URI (optimistic UI)
if (imageBitmap == null && attachment.localUri.isNotEmpty()) {
try {
val uri = android.net.Uri.parse(attachment.localUri)
@@ -917,6 +941,7 @@ fun ImageAttachment(
downloadStatus = DownloadStatus.NOT_DOWNLOADED
}
}
}
}
if (imageBitmap == null && downloadStatus == DownloadStatus.DOWNLOADED) {

View File

@@ -1,9 +1,12 @@
package com.rosetta.messenger.ui.chats.components
import android.graphics.Bitmap
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import android.graphics.BitmapFactory
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -25,6 +28,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Layout
@@ -200,42 +209,57 @@ fun DateHeader(dateText: String, secondaryTextColor: Color) {
}
}
/** Typing indicator with animated dots (Telegram style) */
/**
* Telegram-style typing indicator — 3 bouncing dots drawn as Canvas circles
* with sequential wave animation (scale + vertical offset + opacity).
*/
@Composable
fun TypingIndicator(isDarkTheme: Boolean) {
val infiniteTransition = rememberInfiniteTransition(label = "typing")
val typingColor = Color(0xFF54A9EB)
val infiniteTransition = rememberInfiniteTransition(label = "typing")
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
// Each dot animates through a 0→1→0 cycle, staggered by 150 ms
val dotProgresses = List(3) { index ->
infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1200
0f at 0 with LinearEasing
1f at 300 with FastOutSlowInEasing
0f at 600 with FastOutSlowInEasing
0f at 1200 with LinearEasing
},
repeatMode = RepeatMode.Restart,
initialStartOffset = StartOffset(index * 150)
),
label = "dot$index"
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = "typing", fontSize = 13.sp, color = typingColor)
Spacer(modifier = Modifier.width(2.dp))
repeat(3) { index ->
val offsetY by
infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = -4f,
animationSpec =
infiniteRepeatable(
animation =
tween(
durationMillis = 600,
delayMillis = index * 100,
easing = FastOutSlowInEasing
),
repeatMode = RepeatMode.Reverse
),
label = "dot$index"
// Fixed-size canvas — big enough for bounce, never changes layout
Canvas(modifier = Modifier.size(width = 18.dp, height = 14.dp)) {
val dotRadius = 1.5.dp.toPx()
val dotSpacing = 2.5.dp.toPx()
val maxBounce = 2.dp.toPx()
val centerY = size.height / 2f + 1.dp.toPx()
for (i in 0..2) {
val p = dotProgresses[i].value
val bounce = kotlin.math.sin(p * Math.PI).toFloat()
val cx = dotRadius + i * (dotRadius * 2 + dotSpacing)
val cy = centerY - bounce * maxBounce
val alpha = 0.4f + bounce * 0.6f
drawCircle(
color = typingColor.copy(alpha = alpha),
radius = dotRadius,
center = androidx.compose.ui.geometry.Offset(cx, cy)
)
Text(
text = ".",
fontSize = 13.sp,
color = typingColor,
modifier = Modifier.offset(y = offsetY.dp)
)
}
}
}
}
@@ -264,7 +288,8 @@ fun MessageBubble(
onRetry: () -> Unit = {},
onDelete: () -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
contextMenuContent: @Composable () -> Unit = {}
) {
// Swipe-to-reply state
var swipeOffset by remember { mutableStateOf(0f) }
@@ -317,7 +342,7 @@ fun MessageBubble(
else Color(0xFF2196F3) // Стандартный Material Blue для входящих
}
val linksEnabled = !isSelectionMode
val textClickHandler: (() -> Unit)? = if (isSelectionMode) onClick else null
val textClickHandler: (() -> Unit)? = onClick
val timeColor =
remember(message.isOutgoing, isDarkTheme) {
@@ -1010,6 +1035,8 @@ fun MessageBubble(
}
}
}
// 💬 Context menu anchor (DropdownMenu positions relative to this Box)
contextMenuContent()
}
}
}
@@ -1872,6 +1899,451 @@ private fun SkeletonBubble(
}
}
// ═══════════════════════════════════════════════════════════
// 📌 PINNED MESSAGE BANNER (Telegram-style)
// ═══════════════════════════════════════════════════════════
/**
* Telegram-style pinned message banner — отображается под хедером чата.
* При клике скроллит к текущему pinned сообщению и переключает на следующее.
*/
@Composable
fun PinnedMessageBanner(
pinnedCount: Int,
currentIndex: Int,
messagePreview: String,
isDarkTheme: Boolean,
onBannerClick: () -> Unit,
onCloseClick: () -> Unit
) {
val bannerBg = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
val accentColor = PrimaryBlue
// 📌 Animated text transition (slide up/down like Telegram)
var previousIndex by remember { mutableStateOf(currentIndex) }
var previousPreview by remember { mutableStateOf(messagePreview) }
val slideDirection = if (currentIndex > previousIndex) 1 else -1 // 1 = вверх, -1 = вниз
val transition = updateTransition(targetState = currentIndex, label = "pinnedTransition")
val offsetY by transition.animateFloat(
transitionSpec = { tween(durationMillis = 200, easing = FastOutSlowInEasing) },
label = "offsetY"
) { targetIdx ->
if (targetIdx == previousIndex) 0f else 0f
}
// Track text for outgoing animation
LaunchedEffect(currentIndex) {
previousIndex = currentIndex
previousPreview = messagePreview
}
Box(
modifier = Modifier
.fillMaxWidth()
.background(bannerBg)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) { onBannerClick() }
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 📌 Telegram-style PinnedLineView (animated vertical segments indicator)
PinnedLineIndicator(
totalCount = pinnedCount,
selectedIndex = currentIndex,
accentColor = accentColor,
isDarkTheme = isDarkTheme,
modifier = Modifier
.width(2.dp)
.height(32.dp)
)
Spacer(modifier = Modifier.width(12.dp))
// Текст с анимацией
Column(
modifier = Modifier
.weight(1f)
.clipToBounds()
) {
// Title: "Pinned Message" или "Pinned Message #N"
AnimatedContent(
targetState = currentIndex,
transitionSpec = {
val direction = if (targetState > initialState) 1 else -1
(slideInVertically { height -> direction * height } + fadeIn(tween(200)))
.togetherWith(slideOutVertically { height -> -direction * height } + fadeOut(tween(150)))
},
label = "pinnedTitle"
) { idx ->
Text(
text = if (pinnedCount > 1) "Pinned Message #${pinnedCount - idx}" else "Pinned Message",
color = accentColor,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1
)
}
// Preview text
AnimatedContent(
targetState = messagePreview,
transitionSpec = {
(slideInVertically { height -> height } + fadeIn(tween(200)))
.togetherWith(slideOutVertically { height -> -height } + fadeOut(tween(150)))
},
label = "pinnedPreview"
) { preview ->
Text(
text = preview,
color = textColor,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
// Кнопка закрытия (unpin)
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) { onCloseClick() },
contentAlignment = Alignment.Center
) {
Icon(
painter = TelegramIcons.Close,
contentDescription = "Unpin message",
tint = secondaryColor,
modifier = Modifier.size(16.dp)
)
}
}
// Bottom divider
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(0.5.dp)
.background(
if (isDarkTheme) Color.White.copy(alpha = 0.08f)
else Color.Black.copy(alpha = 0.06f)
)
)
}
}
/**
* 📌 Telegram-style vertical line indicator for pinned messages.
* Shows segments for each pin (max 3 visible), active segment highlighted.
* Animates position and count transitions with 220ms cubic-bezier.
* Based on Telegram's PinnedLineView.java
*/
@Composable
private fun PinnedLineIndicator(
totalCount: Int,
selectedIndex: Int,
accentColor: Color,
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
// Inactive color = accent with ~44% alpha (matches Telegram: alpha * 112/255)
val inactiveColor = accentColor.copy(alpha = 0.44f)
val activeColor = accentColor
// Animate position changes
// Invert: index 0 (newest, DESC) → bottom segment, index N-1 (oldest) → top segment (Telegram-style)
val visualPosition = (totalCount - 1 - selectedIndex).coerceAtLeast(0)
val animatedPosition by animateFloatAsState(
targetValue = visualPosition.toFloat(),
animationSpec = tween(durationMillis = 220, easing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f)),
label = "pinnedLinePosition"
)
val animatedCount by animateFloatAsState(
targetValue = totalCount.toFloat(),
animationSpec = tween(durationMillis = 220, easing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f)),
label = "pinnedLineCount"
)
if (totalCount <= 1) {
// Single pin — just a solid accent line
Box(
modifier = modifier
.clip(RoundedCornerShape(1.dp))
.background(activeColor)
)
} else {
Canvas(modifier = modifier) {
val viewPadding = 2.dp.toPx()
val maxVisible = 3
val visibleCount = minOf(totalCount, maxVisible)
val lineH = (size.height - viewPadding * 2) / visibleCount.toFloat()
if (lineH <= 0f) return@Canvas
val linePadding = 1.dp.toPx()
val cornerRadius = size.width / 2f
// Calculate scroll offset only when totalCount > maxVisible
var startOffset = 0f
if (totalCount > maxVisible) {
// Keep selected segment visible in the viewport
startOffset = (animatedPosition - 1) * lineH
if (startOffset < 0f) startOffset = 0f
val maxOffset = (totalCount - maxVisible).toFloat() * lineH
if (startOffset > maxOffset) startOffset = maxOffset
}
// Draw visible segments
val start = maxOf(0, ((startOffset) / lineH).toInt() - 1)
val end = minOf(start + maxVisible + 2, totalCount)
for (i in start until end) {
val y = viewPadding + i * lineH - startOffset
if (y + lineH < 0f || y > size.height) continue
drawRoundRect(
color = inactiveColor,
topLeft = Offset(0f, y + linePadding),
size = Size(size.width, lineH - linePadding * 2),
cornerRadius = CornerRadius(cornerRadius, cornerRadius)
)
}
// Draw active (selected) segment on top
val activeY = viewPadding + animatedPosition * lineH - startOffset
drawRoundRect(
color = activeColor,
topLeft = Offset(0f, activeY + linePadding),
size = Size(size.width, lineH - linePadding * 2),
cornerRadius = CornerRadius(cornerRadius, cornerRadius)
)
// Fade edges when scrollable (>3 pins)
if (totalCount > maxVisible) {
// Top fade
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.Transparent.copy(alpha = 0.6f), Color.Transparent),
startY = 0f,
endY = 4.dp.toPx()
),
size = Size(size.width, 4.dp.toPx()),
blendMode = BlendMode.DstOut
)
// Bottom fade
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.Transparent, Color.Transparent.copy(alpha = 0.6f)),
startY = size.height - 4.dp.toPx(),
endY = size.height
),
topLeft = Offset(0f, size.height - 4.dp.toPx()),
size = Size(size.width, 4.dp.toPx()),
blendMode = BlendMode.DstOut
)
}
}
}
}
// ═══════════════════════════════════════════════════════════
// 💬 MESSAGE CONTEXT MENU (Telegram-style long press menu)
// ═══════════════════════════════════════════════════════════
/**
* Telegram-style context menu — появляется при long press на сообщении.
* Содержит: Reply, Copy, Forward, Pin/Unpin, Delete.
*/
@Composable
fun MessageContextMenu(
expanded: Boolean,
onDismiss: () -> Unit,
isDarkTheme: Boolean,
isPinned: Boolean,
isOutgoing: Boolean,
hasText: Boolean = true,
isSystemAccount: Boolean = false,
onReply: () -> Unit,
onCopy: () -> Unit,
onForward: () -> Unit,
onPin: () -> Unit,
onDelete: () -> Unit
) {
val menuBgColor = if (isDarkTheme) Color(0xFF272829) else Color.White
val textColor = if (isDarkTheme) Color.White else Color(0xFF222222)
val iconColor = if (isDarkTheme) Color.White.copy(alpha = 0.47f) else Color(0xFF676B70)
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(
surface = menuBgColor,
onSurface = textColor
)
) {
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismiss,
modifier = Modifier
.defaultMinSize(minWidth = 196.dp)
.background(menuBgColor),
properties = PopupProperties(
focusable = false,
dismissOnBackPress = true,
dismissOnClickOutside = true
)
) {
// Reply
if (!isSystemAccount) {
ContextMenuItem(
icon = TelegramIcons.Reply,
text = "Reply",
onClick = {
onDismiss()
onReply()
},
tintColor = iconColor,
textColor = textColor
)
}
// Copy (только если есть текст)
if (hasText) {
ContextMenuItemWithVector(
icon = Icons.Default.ContentCopy,
text = "Copy",
onClick = {
onDismiss()
onCopy()
},
tintColor = iconColor,
textColor = textColor
)
}
// Forward
if (!isSystemAccount) {
ContextMenuItem(
icon = TelegramIcons.Reply,
text = "Forward",
onClick = {
onDismiss()
onForward()
},
tintColor = iconColor,
textColor = textColor,
mirrorIcon = true
)
}
// Pin / Unpin
ContextMenuItem(
icon = if (isPinned) TelegramIcons.Unpin else TelegramIcons.Pin,
text = if (isPinned) "Unpin" else "Pin",
onClick = {
onDismiss()
onPin()
},
tintColor = iconColor,
textColor = textColor
)
// Delete
ContextMenuItem(
icon = TelegramIcons.Delete,
text = "Delete",
onClick = {
onDismiss()
onDelete()
},
tintColor = Color(0xFFFF3B30),
textColor = Color(0xFFFF3B30)
)
}
}
}
@Composable
private fun ContextMenuItem(
icon: Painter,
text: String,
onClick: () -> Unit,
tintColor: Color,
textColor: Color,
mirrorIcon: Boolean = false
) {
Box(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minWidth = 196.dp, minHeight = 48.dp)
.clickable(onClick = onClick)
.padding(horizontal = 18.dp),
contentAlignment = Alignment.CenterStart
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = icon,
contentDescription = null,
tint = tintColor,
modifier = Modifier
.size(24.dp)
.then(
if (mirrorIcon) Modifier.graphicsLayer { scaleX = -1f }
else Modifier
)
)
Spacer(modifier = Modifier.width(19.dp))
Text(
text = text,
color = textColor,
fontSize = 16.sp
)
}
}
}
@Composable
private fun ContextMenuItemWithVector(
icon: ImageVector,
text: String,
onClick: () -> Unit,
tintColor: Color,
textColor: Color
) {
Box(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minWidth = 196.dp, minHeight = 48.dp)
.clickable(onClick = onClick)
.padding(horizontal = 18.dp),
contentAlignment = Alignment.CenterStart
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = icon,
contentDescription = null,
tint = tintColor,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(19.dp))
Text(
text = text,
color = textColor,
fontSize = 16.sp
)
}
}
}
/** Telegram-style kebab menu */
@Composable
fun KebabMenu(