fix: update message logging format for consistency and clarity

This commit is contained in:
2026-02-06 02:01:03 +05:00
parent c455994224
commit 3a810d6d61
10 changed files with 393 additions and 125 deletions

View File

@@ -43,6 +43,7 @@ import com.rosetta.messenger.R
import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
@@ -351,26 +352,48 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text =
when (protocolState) {
ProtocolState
.DISCONNECTED ->
"Disconnected"
ProtocolState.CONNECTING ->
"Connecting..."
ProtocolState.CONNECTED ->
"Connected"
ProtocolState.HANDSHAKING ->
"Authenticating..."
ProtocolState
.AUTHENTICATED ->
"Authenticated"
},
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = textColor
)
when (protocolState) {
ProtocolState.DISCONNECTED -> {
Text(
text = "Disconnected",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = textColor
)
}
ProtocolState.CONNECTING -> {
AnimatedDotsText(
baseText = "Connecting",
color = textColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
ProtocolState.CONNECTED -> {
Text(
text = "Connected",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = textColor
)
}
ProtocolState.HANDSHAKING -> {
AnimatedDotsText(
baseText = "Authenticating",
color = textColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
ProtocolState.AUTHENTICATED -> {
Text(
text = "Authenticated",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = textColor
)
}
}
}
}
},

View File

@@ -156,8 +156,10 @@ if (currentAccount == publicKey) {
val actualFromMe = lastMsgStatus?.fromMe ?: 0
val actualDelivered = if (actualFromMe == 1) (lastMsgStatus?.delivered ?: 0) else 0
val actualRead = if (actualFromMe == 1) (lastMsgStatus?.read ?: 0) else 0
// 📎 Определяем тип attachment последнего сообщения
// 🔥 Reply vs Forward: если есть текст - это Reply (показываем текст),
// если текст пустой - это Forward (показываем "Forwarded message")
val attachmentType = try {
val attachmentsJson = messageDao.getLastMessageAttachments(publicKey, dialog.opponentKey)
if (!attachmentsJson.isNullOrEmpty() && attachmentsJson != "[]") {
@@ -167,7 +169,12 @@ if (currentAccount == publicKey) {
val type = firstAttachment.optInt("type", -1)
when (type) {
0 -> "Photo" // AttachmentType.IMAGE = 0
1 -> "Forwarded" // AttachmentType.MESSAGES = 1
1 -> {
// AttachmentType.MESSAGES = 1 (Reply или Forward)
// Reply: есть текст сообщения -> показываем текст (null)
// Forward: текст пустой -> показываем "Forwarded"
if (decryptedLastMessage.isNotEmpty()) null else "Forwarded"
}
2 -> "File" // AttachmentType.FILE = 2
3 -> "Avatar" // AttachmentType.AVATAR = 3
else -> null
@@ -177,7 +184,7 @@ if (currentAccount == publicKey) {
} catch (e: Exception) {
null
}
DialogUiModel(
id = dialog.id,
account = dialog.account,
@@ -242,8 +249,10 @@ if (currentAccount == publicKey) {
} catch (e: Exception) {
dialog.lastMessage
}
// 📎 Определяем тип attachment последнего сообщения
// 🔥 Reply vs Forward: если есть текст - это Reply (показываем текст),
// если текст пустой - это Forward (показываем "Forwarded message")
val attachmentType = try {
val attachmentsJson = messageDao.getLastMessageAttachments(publicKey, dialog.opponentKey)
if (!attachmentsJson.isNullOrEmpty() && attachmentsJson != "[]") {
@@ -253,7 +262,12 @@ if (currentAccount == publicKey) {
val type = firstAttachment.optInt("type", -1)
when (type) {
0 -> "Photo" // AttachmentType.IMAGE = 0
1 -> "Forwarded" // AttachmentType.MESSAGES = 1
1 -> {
// AttachmentType.MESSAGES = 1 (Reply или Forward)
// Reply: есть текст сообщения -> показываем текст (null)
// Forward: текст пустой -> показываем "Forwarded"
if (decryptedLastMessage.isNotEmpty()) null else "Forwarded"
}
2 -> "File" // AttachmentType.FILE = 2
3 -> "Avatar" // AttachmentType.AVATAR = 3
else -> null
@@ -263,7 +277,7 @@ if (currentAccount == publicKey) {
} catch (e: Exception) {
null
}
DialogUiModel(
id = dialog.id,
account = dialog.account,

View File

@@ -49,6 +49,7 @@ import com.vanniktech.blurhash.BlurHash
import compose.icons.TablerIcons
import compose.icons.tablericons.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.compose.ui.platform.LocalConfiguration
@@ -56,6 +57,48 @@ import kotlin.math.min
private const val TAG = "AttachmentComponents"
/**
* 🔄 Анимированный текст с точками (Downloading... → Downloading. → Downloading.. → Downloading...)
* Как в Telegram - точки плавно появляются и исчезают
*/
@Composable
fun AnimatedDotsText(
baseText: String,
color: Color,
fontSize: androidx.compose.ui.unit.TextUnit = 12.sp,
fontWeight: FontWeight = FontWeight.Normal
) {
var dotCount by remember { mutableIntStateOf(0) }
// Анимация точек: 0 → 1 → 2 → 3 → 0 → ...
LaunchedEffect(Unit) {
while (true) {
delay(400) // Интервал между изменениями
dotCount = (dotCount + 1) % 4
}
}
val dots = ".".repeat(dotCount)
// Добавляем невидимые точки для фиксированной ширины текста
val invisibleDots = ".".repeat(3 - dotCount)
Row {
Text(
text = "$baseText$dots",
fontSize = fontSize,
fontWeight = fontWeight,
color = color
)
// Невидимые точки для сохранения ширины
Text(
text = invisibleDots,
fontSize = fontSize,
fontWeight = fontWeight,
color = Color.Transparent
)
}
}
/**
* 🖼️ Глобальный LRU кэш для bitmap'ов изображений
* Это предотвращает мигание при перезаходе в диалог,
@@ -1315,24 +1358,40 @@ fun FileAttachment(
// Размер файла и тип
val fileExtension = fileName.substringAfterLast('.', "").uppercase()
Text(
text =
when (downloadStatus) {
DownloadStatus.DOWNLOADING -> "Downloading..."
DownloadStatus.DECRYPTING -> "Decrypting..."
DownloadStatus.ERROR -> "File expired"
else -> "${formatFileSize(fileSize)} $fileExtension"
},
fontSize = 12.sp,
color =
if (downloadStatus == DownloadStatus.ERROR) {
Color(0xFFE53935)
} else if (isOutgoing) {
Color.White.copy(alpha = 0.7f)
} else {
PrimaryBlue
}
)
val statusColor = if (downloadStatus == DownloadStatus.ERROR) {
Color(0xFFE53935)
} else if (isOutgoing) {
Color.White.copy(alpha = 0.7f)
} else {
PrimaryBlue
}
when (downloadStatus) {
DownloadStatus.DOWNLOADING -> {
AnimatedDotsText(
baseText = "Downloading",
color = statusColor,
fontSize = 12.sp
)
}
DownloadStatus.DECRYPTING -> {
AnimatedDotsText(
baseText = "Decrypting",
color = statusColor,
fontSize = 12.sp
)
}
else -> {
Text(
text = when (downloadStatus) {
DownloadStatus.ERROR -> "File expired"
else -> "${formatFileSize(fileSize)} $fileExtension"
},
fontSize = 12.sp,
color = statusColor
)
}
}
}
}
@@ -1692,20 +1751,36 @@ fun AvatarAttachment(
Spacer(modifier = Modifier.height(2.dp))
// Описание статуса
Text(
text =
when (downloadStatus) {
DownloadStatus.DOWNLOADING -> "Downloading..."
DownloadStatus.DECRYPTING -> "Decrypting..."
DownloadStatus.ERROR -> "Tap to retry"
DownloadStatus.DOWNLOADED -> "Shared profile photo"
else -> "Tap to download"
},
fontSize = 13.sp,
color =
if (isOutgoing) Color.White.copy(alpha = 0.7f)
else (if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray)
)
val avatarStatusColor = if (isOutgoing) Color.White.copy(alpha = 0.7f)
else (if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray)
when (downloadStatus) {
DownloadStatus.DOWNLOADING -> {
AnimatedDotsText(
baseText = "Downloading",
color = avatarStatusColor,
fontSize = 13.sp
)
}
DownloadStatus.DECRYPTING -> {
AnimatedDotsText(
baseText = "Decrypting",
color = avatarStatusColor,
fontSize = 13.sp
)
}
else -> {
Text(
text = when (downloadStatus) {
DownloadStatus.ERROR -> "Tap to retry"
DownloadStatus.DOWNLOADED -> "Shared profile photo"
else -> "Tap to download"
},
fontSize = 13.sp,
color = avatarStatusColor
)
}
}
Spacer(modifier = Modifier.height(4.dp))

View File

@@ -626,7 +626,8 @@ fun MessageBubble(
text = message.text,
color = textColor,
fontSize = 16.sp,
linkColor = linkColor
linkColor = linkColor,
onLongClick = onLongClick // 🔥 Long press для selection
)
},
timeContent = {
@@ -673,7 +674,8 @@ fun MessageBubble(
text = message.text,
color = textColor,
fontSize = 17.sp,
linkColor = linkColor
linkColor = linkColor,
onLongClick = onLongClick // 🔥 Long press для selection
)
},
timeContent = {
@@ -701,7 +703,8 @@ fun MessageBubble(
)
}
}
}
},
fillWidth = true // 🔥 Время справа с учётом ширины ReplyBubble
)
} else if (!hasOnlyMedia && !hasImageWithCaption && message.text.isNotEmpty()) {
// Telegram-style: текст + время с автоматическим переносом
@@ -711,7 +714,8 @@ fun MessageBubble(
text = message.text,
color = textColor,
fontSize = 17.sp,
linkColor = linkColor
linkColor = linkColor,
onLongClick = onLongClick // 🔥 Long press для selection
)
},
timeContent = {

View File

@@ -36,6 +36,9 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
@@ -387,6 +390,13 @@ fun ImageEditorScreen(
Box(
modifier = Modifier
.fillMaxSize()
// 🔥 Блокируем свайпы от SwipeBackContainer - на ImageEditor свайпы не должны работать
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
down.consume() // Поглощаем все touch события
}
}
.graphicsLayer { alpha = animationProgress.value } // ⚡ Всё плавно fade
.background(Color.Black)
) {
@@ -1067,33 +1077,20 @@ private fun TelegramCaptionBar(
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
// 🎬 Левая иконка: плавная анимация через alpha
// Камера (compact) ↔ Emoji/Keyboard (expanded)
// Emoji/Keyboard toggle - всегда виден
Box(
modifier = Modifier.size(32.dp),
contentAlignment = Alignment.Center
) {
// Камера - видна когда progress близок к 0
Icon(
TablerIcons.CameraPlus,
contentDescription = "Camera",
tint = Color.White.copy(alpha = 0.7f * (1f - progress)),
modifier = Modifier
.size(26.dp)
.graphicsLayer { alpha = 1f - progress }
)
// Emoji/Keyboard toggle - виден когда progress близок к 1
// Emoji/Keyboard toggle
IconButton(
onClick = onToggleEmojiPicker,
modifier = Modifier
.size(32.dp)
.graphicsLayer { alpha = progress },
enabled = progress > 0.5f // Кликабелен только когда достаточно виден
modifier = Modifier.size(32.dp)
) {
Icon(
if (showEmojiPicker) TablerIcons.Keyboard else TablerIcons.MoodSmile,
contentDescription = if (showEmojiPicker) "Keyboard" else "Emoji",
tint = Color.White.copy(alpha = 0.7f * progress),
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(26.dp)
)
}

View File

@@ -69,6 +69,24 @@ private const val TAG = "ImageViewerScreen"
*/
private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
/**
* Linear interpolation helper
*/
private fun lerp(start: Float, stop: Float, fraction: Float): Float {
return start + (stop - start) * fraction
}
/**
* Transform data for shared element animation
*/
private data class ImageTransform(
val scaleX: Float,
val scaleY: Float,
val translationX: Float,
val translationY: Float,
val cornerRadius: Float
)
/**
* Данные об источнике изображения для shared element transition
*/
@@ -196,8 +214,47 @@ fun ImageViewerScreen(
var showControls by remember { mutableStateOf(true) }
// ═══════════════════════════════════════════════════════════
// 🎬 SHARED ELEMENT TRANSFORM - вычисляем трансформацию
// ═══════════════════════════════════════════════════════════
// 🎬 ПРОСТОЙ FADE-OUT при свайпе
val imageTransform by remember(sourceBounds, screenSize, animationProgress.value) {
derivedStateOf {
if (sourceBounds != null && screenSize != IntSize.Zero) {
val progress = animationProgress.value
// Вычисляем scale для перехода от источника к fullscreen
val sourceScaleX = sourceBounds.width / screenSize.width.toFloat()
val sourceScaleY = sourceBounds.height / screenSize.height.toFloat()
val currentScaleX = lerp(sourceScaleX, 1f, progress)
val currentScaleY = lerp(sourceScaleY, 1f, progress)
// Вычисляем translation для центрирования
val targetCenterX = screenSize.width / 2f
val targetCenterY = screenSize.height / 2f
val sourceCenterX = sourceBounds.left + sourceBounds.width / 2f
val sourceCenterY = sourceBounds.top + sourceBounds.height / 2f
val currentTranslationX = lerp(sourceCenterX - targetCenterX, 0f, progress)
val currentTranslationY = lerp(sourceCenterY - targetCenterY, 0f, progress)
// Скругление углов: от sourceBounds.cornerRadius до 0
val currentCornerRadius = lerp(sourceBounds.cornerRadius, 0f, progress)
ImageTransform(
scaleX = currentScaleX,
scaleY = currentScaleY,
translationX = currentTranslationX,
translationY = currentTranslationY,
cornerRadius = currentCornerRadius
)
} else {
ImageTransform(1f, 1f, 0f, 0f, 0f)
}
}
}
// ═══════════════════════════════════════════════════════════
// 🎬 SWIPE TO DISMISS
// ═══════════════════════════════════════════════════════════
var dragOffsetY by remember { mutableFloatStateOf(0f) }
var isDragging by remember { mutableStateOf(false) }
@@ -216,21 +273,35 @@ fun ImageViewerScreen(
}
}
// 🎬 Простой fade-out dismiss
// 🎬 Shared element dismiss - возврат к исходному положению
fun smoothDismiss() {
if (isClosing) return
isClosing = true
onClosingStart() // Сразу сбрасываем status bar
scope.launch {
// Плавно исчезаем - более длинная анимация
dismissAlpha.animateTo(
targetValue = 0f,
animationSpec = tween(
durationMillis = 350,
easing = LinearEasing
// Сначала сбрасываем offset от drag
animatedOffsetY.snapTo(0f)
if (sourceBounds != null && pagerState.currentPage == initialIndex) {
// 🔥 Telegram-style: возвращаемся к исходному положению с shared element
animationProgress.animateTo(
targetValue = 0f,
animationSpec = tween(
durationMillis = 280,
easing = FastOutSlowInEasing
)
)
)
} else {
// Fallback: простой fade-out если пролистнули на другое фото
dismissAlpha.animateTo(
targetValue = 0f,
animationSpec = tween(
durationMillis = 250,
easing = LinearEasing
)
)
}
onDismiss()
}
}
@@ -269,6 +340,11 @@ fun ImageViewerScreen(
// ═══════════════════════════════════════════════════════════
// 📸 HORIZONTAL PAGER с Telegram-style эффектом наслаивания
// ═══════════════════════════════════════════════════════════
// 🔥 Применяем shared element transform только для текущей страницы при анимации
val isAnimating = animationProgress.value < 1f
val shouldApplyTransform = isAnimating && pagerState.currentPage == initialIndex
HorizontalPager(
state = pagerState,
modifier = Modifier
@@ -276,9 +352,25 @@ fun ImageViewerScreen(
.graphicsLayer {
alpha = dismissAlpha.value
translationY = animatedOffsetY.value
},
// 🔥 Shared element transform при входе/выходе
if (shouldApplyTransform) {
scaleX = imageTransform.scaleX
scaleY = imageTransform.scaleY
translationX = imageTransform.translationX
translationY = imageTransform.translationY
}
}
.then(
if (shouldApplyTransform && imageTransform.cornerRadius > 0f) {
Modifier.clip(RoundedCornerShape(imageTransform.cornerRadius.dp))
} else {
Modifier
}
),
pageSpacing = 30.dp, // Telegram: dp(30) между фото
key = { images[it].attachmentId }
key = { images[it].attachmentId },
userScrollEnabled = !isAnimating // Отключаем скролл во время анимации
) { page ->
val image = images[page]

View File

@@ -59,6 +59,7 @@ import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import coil.compose.AsyncImage
import coil.request.ImageRequest
import compose.icons.TablerIcons
@@ -364,23 +365,38 @@ fun MediaPickerBottomSheet(
}
}
// 🎨 Затемнение статус бара когда галерея открыта
// 🎨 Затемнение статус бара и навигейшн бара когда галерея открыта
val view = LocalView.current
// 🔥 Контроллер для управления цветом иконок navigation bar
val insetsController = remember(view) {
val window = (view.context as? android.app.Activity)?.window
window?.let { WindowCompat.getInsetsController(it, view) }
}
DisposableEffect(shouldShow, scrimAlpha) {
if (shouldShow && !view.isInEditMode) {
val window = (view.context as? android.app.Activity)?.window
val originalStatusBarColor = window?.statusBarColor ?: 0
// Затемняем статус бар
val originalNavigationBarColor = window?.navigationBarColor ?: 0
val originalLightNavigationBars = insetsController?.isAppearanceLightNavigationBars ?: true
// Затемняем статус бар и навигейшн бар
val scrimColor = android.graphics.Color.argb(
(scrimAlpha * 255).toInt().coerceIn(0, 255),
0, 0, 0
)
window?.statusBarColor = scrimColor
window?.navigationBarColor = scrimColor
// 🔥 Иконки навигейшн бара светлые (белые) когда scrim виден
insetsController?.isAppearanceLightNavigationBars = scrimAlpha < 0.15f && originalLightNavigationBars
onDispose {
// Восстанавливаем оригинальный цвет
// Восстанавливаем оригинальные цвета
window?.statusBarColor = originalStatusBarColor
window?.navigationBarColor = originalNavigationBarColor
insetsController?.isAppearanceLightNavigationBars = originalLightNavigationBars
}
} else {
onDispose { }
@@ -413,6 +429,15 @@ fun MediaPickerBottomSheet(
dismissOnClickOutside = false
)
) {
// 🔥 Получаем высоту navigation bar и IME (клавиатуры)
val navigationBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val imeHeight = WindowInsets.ime.asPaddingValues().calculateBottomPadding()
val navigationBarHeightPx = with(density) { navigationBarHeight.toPx().toInt() }
val imeHeightPx = with(density) { imeHeight.toPx().toInt() }
// 🔥 Если клавиатура открыта - не добавляем navigationBar offset (клавиатура его перекрывает)
val bottomOffset = if (imeHeightPx > 0) 0 else navigationBarHeightPx
// Полноэкранный контейнер с мягким затемнением
Box(
modifier = Modifier
@@ -426,11 +451,12 @@ fun MediaPickerBottomSheet(
) {
// Sheet content
val currentHeightDp = with(density) { sheetHeightPx.value.toDp() }
val slideOffset = (sheetHeightPx.value * animatedOffset).toInt()
// 🔥 Добавляем смещение вниз только если клавиатура закрыта
val slideOffset = (sheetHeightPx.value * animatedOffset).toInt() + bottomOffset
// Отслеживаем velocity для плавного snap
var lastDragVelocity by remember { mutableFloatStateOf(0f) }
Column(
modifier = Modifier
.fillMaxWidth()

View File

@@ -17,7 +17,9 @@ import android.text.style.ImageSpan
import android.util.AttributeSet
import android.util.LruCache
import android.util.Patterns
import android.view.GestureDetector
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.EditText
@@ -325,7 +327,8 @@ fun AppleEmojiText(
maxLines: Int = Int.MAX_VALUE,
overflow: android.text.TextUtils.TruncateAt? = null,
linkColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color(0xFF54A9EB), // Telegram-style blue
enableLinks: Boolean = true // 🔥 Включить кликабельные ссылки
enableLinks: Boolean = true, // 🔥 Включить кликабельные ссылки
onLongClick: (() -> Unit)? = null // 🔥 Callback для long press (selection в MessageBubble)
) {
val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f
else fontSize.value
@@ -355,10 +358,13 @@ fun AppleEmojiText(
if (overflow != null) {
ellipsize = overflow
}
// 🔥 Включаем кликабельные ссылки
// 🔥 Включаем кликабельные ссылки с поддержкой long press
if (enableLinks) {
setLinkColor(linkColor.toArgb())
enableClickableLinks(true)
enableClickableLinks(true, onLongClick)
} else {
// 🔥 Даже без ссылок поддерживаем long press
onLongClickCallback = onLongClick
}
}
},
@@ -371,10 +377,13 @@ fun AppleEmojiText(
if (overflow != null) {
view.ellipsize = overflow
}
// 🔥 Обновляем настройки ссылок
// 🔥 Обновляем настройки ссылок с поддержкой long press
if (enableLinks) {
view.setLinkColor(linkColor.toArgb())
view.enableClickableLinks(true)
view.enableClickableLinks(true, onLongClick)
} else {
// 🔥 Даже без ссылок поддерживаем long press
view.onLongClickCallback = onLongClick
}
},
modifier = modifier
@@ -415,11 +424,32 @@ class AppleEmojiTextView @JvmOverloads constructor(
private var linkColorValue: Int = 0xFF54A9EB.toInt() // Default Telegram blue
private var linksEnabled: Boolean = false
// 🔥 Long press callback для selection в MessageBubble
var onLongClickCallback: (() -> Unit)? = null
// 🔥 GestureDetector для обработки long press поверх LinkMovementMethod
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
override fun onLongPress(e: MotionEvent) {
onLongClickCallback?.invoke()
}
})
init {
// Отключаем лишние отступы шрифта для корректного отображения emoji
includeFontPadding = false
}
/**
* 🔥 Перехватываем touch события для обработки long press
* GestureDetector обрабатывает long press, затем передаем событие parent для ссылок
*/
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
// Позволяем GestureDetector обработать событие (для long press)
gestureDetector.onTouchEvent(event)
// Передаем событие дальше для обработки ссылок
return super.dispatchTouchEvent(event)
}
/**
* 🔥 Установить цвет для ссылок
*/
@@ -429,9 +459,12 @@ class AppleEmojiTextView @JvmOverloads constructor(
/**
* 🔥 Включить/выключить кликабельные ссылки
* @param enable - включить ссылки
* @param onLongClick - callback для long press (для selection в MessageBubble)
*/
fun enableClickableLinks(enable: Boolean) {
fun enableClickableLinks(enable: Boolean, onLongClick: (() -> Unit)? = null) {
linksEnabled = enable
onLongClickCallback = onLongClick
if (enable) {
movementMethod = LinkMovementMethod.getInstance()
// Убираем highlight при клике

View File

@@ -76,8 +76,8 @@ fun AvatarImage(
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
// 🔥 FIX: Декодируем только когда avatarKey (timestamp) реально изменился
// НЕ сбрасываем bitmap в null - показываем старый пока грузится новый
LaunchedEffect(avatarKey) {
// Сбрасываем bitmap в null ТОЛЬКО когда аватар удалён (avatars пустой)
LaunchedEffect(avatarKey, avatars.isEmpty()) {
val currentAvatars = avatars
if (currentAvatars.isNotEmpty()) {
val newBitmap = withContext(Dispatchers.IO) {
@@ -87,9 +87,10 @@ fun AvatarImage(
if (newBitmap != null) {
bitmap = newBitmap
}
} else {
// 🔥 FIX: Если аватары удалены - сбрасываем bitmap чтобы показался placeholder
bitmap = null
}
// Если avatars пустой - НЕ сбрасываем bitmap в null
// Placeholder покажется через условие bitmap == null ниже
}
Box(

View File

@@ -38,7 +38,8 @@ object MessageLogger {
if (!isEnabled) return
val shortId = messageId.take(8)
val shortKey = toPublicKey.take(12)
val msg = "📤 SEND | id:\$shortId to:\$shortKey len:\$textLength att:\$attachmentsCount saved:\$isSavedMessages reply:\${replyToMessageId?.take(8) ?: \"-\"}"
val replyStr = replyToMessageId?.take(8) ?: "-"
val msg = "📤 SEND | id:$shortId to:$shortKey len:$textLength att:$attachmentsCount saved:$isSavedMessages reply:$replyStr"
Log.d(TAG, msg)
addToUI(msg)
}
@@ -53,7 +54,7 @@ object MessageLogger {
) {
if (!isEnabled) return
val shortId = messageId.take(8)
val msg = "🔐 ENCRYPT | id:\$shortId content:\${encryptedContentLength}b key:\${encryptedKeyLength}b"
val msg = "🔐 ENCRYPT | id:$shortId content:${encryptedContentLength}b key:${encryptedKeyLength}b"
Log.d(TAG, msg)
addToUI(msg)
}
@@ -69,7 +70,7 @@ object MessageLogger {
if (!isEnabled) return
val shortId = messageId.take(8)
val shortDialog = dialogKey.take(12)
val msg = "💾 DB | id:\$shortId dialog:\$shortDialog new:\$isNew"
val msg = "💾 DB | id:$shortId dialog:$shortDialog new:$isNew"
Log.d(TAG, msg)
addToUI(msg)
}
@@ -85,7 +86,7 @@ object MessageLogger {
if (!isEnabled) return
val shortId = messageId.take(8)
val shortKey = toPublicKey.take(12)
val msg = "📡 PACKET→ | id:\$shortId to:\$shortKey ts:\$timestamp"
val msg = "📡 PACKET→ | id:$shortId to:$shortKey ts:$timestamp"
Log.d(TAG, msg)
addToUI(msg)
}
@@ -96,7 +97,7 @@ object MessageLogger {
fun logSendSuccess(messageId: String, duration: Long) {
if (!isEnabled) return
val shortId = messageId.take(8)
val msg = "✅ SENT | id:\$shortId time:\${duration}ms"
val msg = "✅ SENT | id:$shortId time:${duration}ms"
Log.d(TAG, msg)
addToUI(msg)
}
@@ -107,7 +108,8 @@ object MessageLogger {
fun logSendError(messageId: String, error: Throwable) {
if (!isEnabled) return
val shortId = messageId.take(8)
val msg = "❌ SEND ERR | id:\$shortId err:\${error.message?.take(50)}"
val errMsg = error.message?.take(50) ?: "unknown"
val msg = "❌ SEND ERR | id:$shortId err:$errMsg"
Log.e(TAG, msg, error)
addToUI(msg)
}
@@ -127,7 +129,7 @@ object MessageLogger {
val shortId = messageId.take(8)
val shortFrom = fromPublicKey.take(12)
val shortTo = toPublicKey.take(12)
val msg = "📥 RECV | id:\$shortId from:\$shortFrom to:\$shortTo len:\${contentLength}b att:\$attachmentsCount ts:\$timestamp"
val msg = "📥 RECV | id:$shortId from:$shortFrom to:$shortTo len:${contentLength}b att:$attachmentsCount ts:$timestamp"
Log.d(TAG, msg)
addToUI(msg)
}
@@ -139,7 +141,7 @@ object MessageLogger {
if (!isEnabled) return
val shortId = messageId.take(8)
val status = if (isDuplicate) "DUP" else "✓NEW"
val msg = "🔍 CHECK | id:\$shortId \$status"
val msg = "🔍 CHECK | id:$shortId $status"
Log.d(TAG, msg)
addToUI(msg)
}
@@ -150,7 +152,7 @@ object MessageLogger {
fun logBlockedSender(fromPublicKey: String) {
if (!isEnabled) return
val shortKey = fromPublicKey.take(12)
val msg = "🚫 BLOCKED | from:\$shortKey"
val msg = "🚫 BLOCKED | from:$shortKey"
Log.d(TAG, msg)
addToUI(msg)
}
@@ -165,7 +167,7 @@ object MessageLogger {
) {
if (!isEnabled) return
val shortId = messageId.take(8)
val msg = "🔓 DECRYPT | id:\$shortId text:\${plainTextLength}c att:\$attachmentsCount"
val msg = "🔓 DECRYPT | id:$shortId text:${plainTextLength}c att:$attachmentsCount"
Log.d(TAG, msg)
addToUI(msg)
}
@@ -176,7 +178,8 @@ object MessageLogger {
fun logDecryptionError(messageId: String, error: Throwable) {
if (!isEnabled) return
val shortId = messageId.take(8)
val msg = "❌ DECRYPT ERR | id:\$shortId err:\${error.message?.take(50)}"
val errMsg = error.message?.take(50) ?: "unknown"
val msg = "❌ DECRYPT ERR | id:$shortId err:$errMsg"
Log.e(TAG, msg, error)
addToUI(msg)
}
@@ -187,7 +190,7 @@ object MessageLogger {
fun logReceiveSuccess(messageId: String, duration: Long) {
if (!isEnabled) return
val shortId = messageId.take(8)
val msg = "✅ RECEIVED | id:\$shortId time:\${duration}ms"
val msg = "✅ RECEIVED | id:$shortId time:${duration}ms"
Log.d(TAG, msg)
addToUI(msg)
}
@@ -203,7 +206,7 @@ object MessageLogger {
if (!isEnabled) return
val shortId = messageId.take(8)
val shortKey = toPublicKey.take(12)
val msg = "📬 DELIVERY | id:\$shortId to:\$shortKey status:\$status"
val msg = "📬 DELIVERY | id:$shortId to:$shortKey status:$status"
Log.d(TAG, msg)
addToUI(msg)
}
@@ -217,7 +220,7 @@ object MessageLogger {
) {
if (!isEnabled) return
val shortKey = fromPublicKey.take(12)
val msg = "👁 READ | from:\$shortKey count:\$messagesCount"
val msg = "👁 READ | from:$shortKey count:$messagesCount"
Log.d(TAG, msg)
addToUI(msg)
}
@@ -233,7 +236,7 @@ object MessageLogger {
if (!isEnabled) return
val shortDialog = dialogKey.take(12)
val shortMsg = lastMessage?.take(20) ?: "-"
val msg = "📋 DIALOG | key:\$shortDialog last:\"\$shortMsg\" unread:\$unreadCount"
val msg = "📋 DIALOG | key:$shortDialog last:\"$shortMsg\" unread:$unreadCount"
Log.d(TAG, msg)
addToUI(msg)
}
@@ -244,7 +247,7 @@ object MessageLogger {
fun logCacheUpdate(dialogKey: String, totalMessages: Int) {
if (!isEnabled) return
val shortDialog = dialogKey.take(12)
val msg = "🗃 CACHE | dialog:\$shortDialog total:\$totalMessages"
val msg = "🗃 CACHE | dialog:$shortDialog total:$totalMessages"
Log.d(TAG, msg)
addToUI(msg)
}
@@ -268,6 +271,6 @@ object MessageLogger {
} else {
Log.e(TAG, message)
}
addToUI("\$message")
addToUI("$message")
}
}