Refactor code structure for improved readability and maintainability

This commit is contained in:
k1ngsterr1
2026-02-08 05:47:24 +05:00
parent 0eddd448c7
commit 0d0e1e2c22
2 changed files with 1527 additions and 1261 deletions

View File

@@ -4,10 +4,8 @@ import android.content.Context
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.horizontalDrag
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -28,6 +26,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.VelocityTracker
@@ -1694,6 +1693,8 @@ fun SwipeableDialogItem(
.background(backgroundColor) .background(backgroundColor)
.pointerInput(Unit) { .pointerInput(Unit) {
val velocityTracker = VelocityTracker() val velocityTracker = VelocityTracker()
val touchSlop = viewConfiguration.touchSlop
awaitEachGesture { awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false) val down = awaitFirstDown(requireUnconsumed = false)
@@ -1701,22 +1702,58 @@ fun SwipeableDialogItem(
if (isDrawerOpen) return@awaitEachGesture if (isDrawerOpen) return@awaitEachGesture
velocityTracker.resetTracking() velocityTracker.resetTracking()
var started = false var totalDragX = 0f
try { var totalDragY = 0f
horizontalDrag(down.id) { change -> var passedSlop = false
val dragAmount = change.positionChange().x var claimed = false
// First movement determines direction while (true) {
if (!started) { val event = awaitPointerEvent()
// Swipe right with actions closed — let drawer handle it val change = event.changes.firstOrNull { it.id == down.id }
if (dragAmount > 0 && offsetX == 0f) { ?: break
return@horizontalDrag if (change.changedToUpIgnoreConsumed()) break
}
started = true val delta = change.positionChange()
totalDragX += delta.x
totalDragY += delta.y
if (!passedSlop) {
val dist = kotlin.math.sqrt(
totalDragX * totalDragX + totalDragY * totalDragY
)
if (dist < touchSlop) continue
val dominated = kotlin.math.abs(totalDragX) >
kotlin.math.abs(totalDragY) * 2.0f
when {
// Horizontal left swipe — reveal action buttons
dominated && totalDragX < 0 -> {
passedSlop = true
claimed = true
onSwipeStarted() onSwipeStarted()
change.consume()
} }
// Horizontal right swipe with buttons open — close them
val newOffset = offsetX + dragAmount dominated && totalDragX > 0 && offsetX != 0f -> {
passedSlop = true
claimed = true
change.consume()
}
// Right swipe with buttons closed — let drawer handle
totalDragX > 0 && offsetX == 0f -> break
// Vertical/diagonal — close buttons if open, let LazyColumn scroll
else -> {
if (offsetX != 0f) {
offsetX = 0f
onSwipeClosed()
}
break
}
}
} else {
// Gesture is ours — update offset
val newOffset = offsetX + delta.x
offsetX = newOffset.coerceIn(-swipeWidthPx, 0f) offsetX = newOffset.coerceIn(-swipeWidthPx, 0f)
velocityTracker.addPosition( velocityTracker.addPosition(
change.uptimeMillis, change.uptimeMillis,
@@ -1724,21 +1761,31 @@ fun SwipeableDialogItem(
) )
change.consume() change.consume()
} }
} catch (_: Exception) {
offsetX = 0f
} }
if (started) { // Snap animation
if (claimed) {
val velocity = velocityTracker.calculateVelocity().x val velocity = velocityTracker.calculateVelocity().x
// Telegram-like: fling left (-velocity) OR dragged past 1/3 when {
val shouldOpen = velocity < -300f || // Rightward fling — always close
kotlin.math.abs(offsetX) > swipeWidthPx / 3 velocity > 150f -> {
if (shouldOpen) {
offsetX = -swipeWidthPx
} else {
offsetX = 0f offsetX = 0f
onSwipeClosed() onSwipeClosed()
} }
// Strong leftward fling — always open
velocity < -300f -> {
offsetX = -swipeWidthPx
}
// Past halfway — stay open
kotlin.math.abs(offsetX) > swipeWidthPx / 2 -> {
offsetX = -swipeWidthPx
}
// Less than halfway — close
else -> {
offsetX = 0f
onSwipeClosed()
}
}
} }
} }
} }

View File

@@ -11,10 +11,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import kotlin.math.abs
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -30,13 +27,10 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
@@ -56,6 +50,7 @@ import compose.icons.TablerIcons
import compose.icons.tablericons.* import compose.icons.tablericons.*
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlin.math.abs
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -65,10 +60,9 @@ import kotlinx.coroutines.withContext
*/ */
/** /**
* Telegram-style layout для текста сообщения с временем. * Telegram-style layout для текста сообщения с временем. Если текст + время помещаются в одну
* Если текст + время помещаются в одну строку - располагает их рядом. * строку - располагает их рядом. Если текст длинный и переносится - время встаёт в правый нижний
* Если текст длинный и переносится - время встаёт в правый нижний угол, * угол, под последней строкой текста (как в Telegram).
* под последней строкой текста (как в Telegram).
* *
* @param textContent Composable с текстом сообщения * @param textContent Composable с текстом сообщения
* @param timeContent Composable с временем и статусом * @param timeContent Composable с временем и статусом
@@ -94,13 +88,16 @@ fun TelegramStyleMessageContent(
}, },
modifier = modifier modifier = modifier
) { measurables, constraints -> ) { measurables, constraints ->
require(measurables.size == 2) { "TelegramStyleMessageContent requires exactly 2 children" } require(measurables.size == 2) {
"TelegramStyleMessageContent requires exactly 2 children"
}
val textMeasurable = measurables[0] val textMeasurable = measurables[0]
val timeMeasurable = measurables[1] val timeMeasurable = measurables[1]
// Измеряем время с минимальными constraints // Измеряем время с минимальными constraints
val timePlaceable = timeMeasurable.measure(constraints.copy(minWidth = 0, minHeight = 0)) val timePlaceable =
timeMeasurable.measure(constraints.copy(minWidth = 0, minHeight = 0))
// Измеряем текст с полной доступной шириной // Измеряем текст с полной доступной шириной
val textConstraints = constraints.copy(minWidth = 0, minHeight = 0) val textConstraints = constraints.copy(minWidth = 0, minHeight = 0)
@@ -120,13 +117,15 @@ fun TelegramStyleMessageContent(
// 🔥 Для caption - занимаем всю доступную ширину // 🔥 Для caption - занимаем всю доступную ширину
val w = constraints.maxWidth val w = constraints.maxWidth
// 🔥 ИСПРАВЛЕНИЕ: Проверяем помещается ли текст + время в одну строку // 🔥 ИСПРАВЛЕНИЕ: Проверяем помещается ли текст + время в одну
// строку
val availableForTime = w - textWidth - spacingPx val availableForTime = w - textWidth - spacingPx
if (availableForTime >= timeWidth) { if (availableForTime >= timeWidth) {
// Текст и время помещаются - время справа на одной линии // Текст и время помещаются - время справа на одной линии
val h = maxOf(textPlaceable.height, timePlaceable.height) val h = maxOf(textPlaceable.height, timePlaceable.height)
val tX = w - timeWidth // Время справа val tX = w - timeWidth // Время справа
val tY = h - timePlaceable.height // Время внизу строки (выровнено по baseline) val tY = h - timePlaceable.height // Время внизу строки
// (выровнено по baseline)
LayoutResult(w, h, tX, tY) LayoutResult(w, h, tX, tY)
} else { } else {
// Текст длинный - время на новой строке справа внизу // Текст длинный - время на новой строке справа внизу
@@ -323,9 +322,13 @@ fun MessageBubble(
topEnd = TelegramBubbleSpec.bubbleRadius, topEnd = TelegramBubbleSpec.bubbleRadius,
bottomStart = bottomStart =
if (message.isOutgoing) TelegramBubbleSpec.bubbleRadius if (message.isOutgoing) TelegramBubbleSpec.bubbleRadius
else (if (showTail) TelegramBubbleSpec.nearRadius else TelegramBubbleSpec.bubbleRadius), else
(if (showTail) TelegramBubbleSpec.nearRadius
else TelegramBubbleSpec.bubbleRadius),
bottomEnd = bottomEnd =
if (message.isOutgoing) (if (showTail) TelegramBubbleSpec.nearRadius else TelegramBubbleSpec.bubbleRadius) if (message.isOutgoing)
(if (showTail) TelegramBubbleSpec.nearRadius
else TelegramBubbleSpec.bubbleRadius)
else TelegramBubbleSpec.bubbleRadius else TelegramBubbleSpec.bubbleRadius
) )
} }
@@ -336,9 +339,10 @@ fun MessageBubble(
modifier = modifier =
Modifier.fillMaxWidth().pointerInput(Unit) { Modifier.fillMaxWidth().pointerInput(Unit) {
// 🔥 Простой горизонтальный свайп для reply // 🔥 Простой горизонтальный свайп для reply
// Используем detectHorizontalDragGestures который лучше работает со скроллом // Используем detectHorizontalDragGestures который лучше работает со
// скроллом
detectHorizontalDragGestures( detectHorizontalDragGestures(
onDragStart = { }, onDragStart = {},
onDragEnd = { onDragEnd = {
// Если свайпнули достаточно влево - reply // Если свайпнули достаточно влево - reply
if (swipeOffset <= -swipeThreshold) { if (swipeOffset <= -swipeThreshold) {
@@ -346,15 +350,14 @@ fun MessageBubble(
} }
swipeOffset = 0f swipeOffset = 0f
}, },
onDragCancel = { onDragCancel = { swipeOffset = 0f },
swipeOffset = 0f
},
onHorizontalDrag = { change, dragAmount -> onHorizontalDrag = { change, dragAmount ->
// Только свайп влево (отрицательное значение) // Только свайп влево (отрицательное значение)
if (dragAmount < 0 || swipeOffset < 0) { if (dragAmount < 0 || swipeOffset < 0) {
change.consume() change.consume()
val newOffset = swipeOffset + dragAmount val newOffset = swipeOffset + dragAmount
swipeOffset = newOffset.coerceIn(-maxSwipe, 0f) swipeOffset =
newOffset.coerceIn(-maxSwipe, 0f)
} }
} }
) )
@@ -363,7 +366,9 @@ fun MessageBubble(
// 🔥 Reply icon - справа, появляется при свайпе влево // 🔥 Reply icon - справа, появляется при свайпе влево
Box( Box(
modifier = modifier =
Modifier.align(Alignment.CenterEnd).padding(end = 16.dp).graphicsLayer { Modifier.align(Alignment.CenterEnd)
.padding(end = 16.dp)
.graphicsLayer {
alpha = swipeProgress alpha = swipeProgress
scaleX = swipeProgress scaleX = swipeProgress
scaleY = swipeProgress scaleY = swipeProgress
@@ -466,7 +471,9 @@ fun MessageBubble(
message.text.isEmpty() && message.text.isEmpty() &&
message.replyData == null && message.replyData == null &&
message.attachments.all { message.attachments.all {
it.type == com.rosetta.messenger.network.AttachmentType.IMAGE it.type ==
com.rosetta.messenger.network.AttachmentType
.IMAGE
} }
// Фото + caption (как в Telegram) // Фото + caption (как в Telegram)
@@ -475,7 +482,9 @@ fun MessageBubble(
message.text.isNotEmpty() && message.text.isNotEmpty() &&
message.replyData == null && message.replyData == null &&
message.attachments.all { message.attachments.all {
it.type == com.rosetta.messenger.network.AttachmentType.IMAGE it.type ==
com.rosetta.messenger.network.AttachmentType
.IMAGE
} }
// Для сообщений только с фото - минимальный padding и тонкий border // Для сообщений только с фото - минимальный padding и тонкий border
@@ -495,25 +504,47 @@ fun MessageBubble(
val maxPhotoWidth = TelegramBubbleSpec.maxPhotoWidth(screenWidthDp).dp val maxPhotoWidth = TelegramBubbleSpec.maxPhotoWidth(screenWidthDp).dp
// Вычисляем ширину фото для ограничения пузырька // Вычисляем ширину фото для ограничения пузырька
val photoWidth = if (hasImageWithCaption || hasOnlyMedia) { val photoWidth =
val firstImage = message.attachments.firstOrNull { if (hasImageWithCaption || hasOnlyMedia) {
it.type == com.rosetta.messenger.network.AttachmentType.IMAGE val firstImage =
message.attachments.firstOrNull {
it.type ==
com.rosetta.messenger.network
.AttachmentType.IMAGE
} }
if (firstImage != null && firstImage.width > 0 && firstImage.height > 0) { if (firstImage != null &&
val ar = firstImage.width.toFloat() / firstImage.height.toFloat() firstImage.width > 0 &&
val maxW = TelegramBubbleSpec.maxPhotoWidth(screenWidthDp).toFloat() firstImage.height > 0
) {
val ar =
firstImage.width.toFloat() /
firstImage.height.toFloat()
val maxW =
TelegramBubbleSpec.maxPhotoWidth(
screenWidthDp
)
.toFloat()
var w = if (ar >= 1f) maxW else maxW * 0.75f var w = if (ar >= 1f) maxW else maxW * 0.75f
var h = w / ar var h = w / ar
if (h > TelegramBubbleSpec.maxPhotoHeight) { if (h > TelegramBubbleSpec.maxPhotoHeight) {
h = TelegramBubbleSpec.maxPhotoHeight.toFloat() h =
TelegramBubbleSpec.maxPhotoHeight
.toFloat()
w = h * ar w = h * ar
} }
if (h < TelegramBubbleSpec.minPhotoHeight) { if (h < TelegramBubbleSpec.minPhotoHeight) {
h = TelegramBubbleSpec.minPhotoHeight.toFloat() h =
TelegramBubbleSpec.minPhotoHeight
.toFloat()
w = h * ar w = h * ar
} }
w.coerceIn(TelegramBubbleSpec.minPhotoWidth.toFloat(), maxW).dp w.coerceIn(
TelegramBubbleSpec.minPhotoWidth
.toFloat(),
maxW
)
.dp
} else { } else {
maxPhotoWidth maxPhotoWidth
} }
@@ -521,8 +552,12 @@ fun MessageBubble(
280.dp 280.dp
} }
val bubbleWidthModifier = if (hasImageWithCaption || hasOnlyMedia) { val bubbleWidthModifier =
Modifier.width(photoWidth) // 🔥 Фиксированная ширина = размер фото (убирает лишний отступ) if (hasImageWithCaption || hasOnlyMedia) {
Modifier.width(
photoWidth
) // 🔥 Фиксированная ширина = размер фото (убирает лишний
// отступ)
} else { } else {
Modifier.widthIn(min = 60.dp, max = 280.dp) Modifier.widthIn(min = 60.dp, max = 280.dp)
} }
@@ -539,7 +574,9 @@ fun MessageBubble(
.combinedClickable( .combinedClickable(
indication = null, indication = null,
interactionSource = interactionSource =
remember { MutableInteractionSource() }, remember {
MutableInteractionSource()
},
onClick = onClick, onClick = onClick,
onLongClick = onLongClick onLongClick = onLongClick
) )
@@ -549,16 +586,26 @@ fun MessageBubble(
Modifier.border( Modifier.border(
width = bubbleBorderWidth, width = bubbleBorderWidth,
color = color =
if (message.isOutgoing) { if (message.isOutgoing
Color.White.copy(alpha = 0.15f) ) {
Color.White
.copy(
alpha =
0.15f
)
} else { } else {
if (isDarkTheme) if (isDarkTheme
Color.White.copy( )
alpha = 0.1f Color.White
.copy(
alpha =
0.1f
) )
else else
Color.Black.copy( Color.Black
alpha = 0.08f .copy(
alpha =
0.08f
) )
}, },
shape = bubbleShape shape = bubbleShape
@@ -583,7 +630,8 @@ fun MessageBubble(
// 📎 Attachments (IMAGE, FILE, AVATAR) // 📎 Attachments (IMAGE, FILE, AVATAR)
if (message.attachments.isNotEmpty()) { if (message.attachments.isNotEmpty()) {
val attachmentDisplayStatus = val attachmentDisplayStatus =
if (isSavedMessages) MessageStatus.READ else message.status if (isSavedMessages) MessageStatus.READ
else message.status
MessageAttachments( MessageAttachments(
attachments = message.attachments, attachments = message.attachments,
chachaKey = message.chachaKey, chachaKey = message.chachaKey,
@@ -596,28 +644,42 @@ fun MessageBubble(
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
currentUserPublicKey = currentUserPublicKey, currentUserPublicKey = currentUserPublicKey,
hasCaption = hasImageWithCaption, hasCaption = hasImageWithCaption,
showTail = showTail, // Передаём для формы пузырька showTail = showTail, // Передаём для формы
// пузырька
onImageClick = onImageClick onImageClick = onImageClick
) )
} }
// 🖼️ Caption под фото (Telegram-style) // 🖼️ Caption под фото (Telegram-style)
// Отступ от фото = 9dp, слева = 11dp (исходящие) / 13dp (входящие) // Отступ от фото = 9dp, слева = 11dp (исходящие) / 13dp
// (входящие)
if (hasImageWithCaption) { if (hasImageWithCaption) {
val captionPaddingStart = if (message.isOutgoing) val captionPaddingStart =
TelegramBubbleSpec.captionPaddingStartOutgoing if (message.isOutgoing)
TelegramBubbleSpec
.captionPaddingStartOutgoing
else else
TelegramBubbleSpec.captionPaddingStartIncoming TelegramBubbleSpec
.captionPaddingStartIncoming
Box( Box(
modifier = modifier =
Modifier Modifier.fillMaxWidth() // 🔥
.fillMaxWidth() // 🔥 Растягиваем на ширину фото для правильного позиционирования времени // Растягиваем на ширину
// фото для правильного
// позиционирования времени
.background(bubbleColor) .background(bubbleColor)
.padding( .padding(
start = captionPaddingStart, start =
end = TelegramBubbleSpec.captionPaddingEnd, captionPaddingStart,
top = TelegramBubbleSpec.captionPaddingTop, end =
bottom = TelegramBubbleSpec.captionPaddingBottom TelegramBubbleSpec
.captionPaddingEnd,
top =
TelegramBubbleSpec
.captionPaddingTop,
bottom =
TelegramBubbleSpec
.captionPaddingBottom
) )
) { ) {
TelegramStyleMessageContent( TelegramStyleMessageContent(
@@ -626,48 +688,82 @@ fun MessageBubble(
text = message.text, text = message.text,
color = textColor, color = textColor,
fontSize = 16.sp, fontSize = 16.sp,
linkColor = linkColor, linkColor =
onLongClick = onLongClick // 🔥 Long press для selection linkColor,
onLongClick =
onLongClick // 🔥 Long press для selection
) )
}, },
timeContent = { timeContent = {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment =
horizontalArrangement = Arrangement.spacedBy(2.dp) Alignment
.CenterVertically,
horizontalArrangement =
Arrangement
.spacedBy(
2.dp
)
) { ) {
Text( Text(
text = timeFormat.format(message.timestamp), text =
color = timeColor, timeFormat
fontSize = 11.sp, .format(
message.timestamp
),
color =
timeColor,
fontSize =
11.sp,
fontStyle = fontStyle =
androidx.compose.ui.text.font.FontStyle androidx.compose
.ui
.text
.font
.FontStyle
.Italic .Italic
) )
if (message.isOutgoing) { if (message.isOutgoing
) {
val displayStatus = val displayStatus =
if (isSavedMessages) MessageStatus.READ if (isSavedMessages
else message.status )
MessageStatus
.READ
else
message.status
AnimatedMessageStatus( AnimatedMessageStatus(
status = displayStatus, status =
timeColor = timeColor, displayStatus,
timestamp = message.timestamp.time, timeColor =
onRetry = onRetry, timeColor,
onDelete = onDelete timestamp =
message.timestamp
.time,
onRetry =
onRetry,
onDelete =
onDelete
) )
} }
} }
}, },
spacing = 8, spacing = 8,
fillWidth = true // 🔥 Время справа на всю ширину фото fillWidth =
true // 🔥 Время справа на
// всю ширину фото
) )
} }
} else if (message.attachments.isNotEmpty() && message.text.isNotEmpty()) { } else if (message.attachments.isNotEmpty() &&
message.text.isNotEmpty()
) {
// Обычное фото + текст (не только изображения) // Обычное фото + текст (не только изображения)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
// Если есть reply - Telegram-style layout // Если есть reply - Telegram-style layout
if (message.replyData != null && message.text.isNotEmpty()) { if (message.replyData != null && message.text.isNotEmpty()
) {
TelegramStyleMessageContent( TelegramStyleMessageContent(
textContent = { textContent = {
AppleEmojiText( AppleEmojiText(
@@ -675,39 +771,74 @@ fun MessageBubble(
color = textColor, color = textColor,
fontSize = 17.sp, fontSize = 17.sp,
linkColor = linkColor, linkColor = linkColor,
onLongClick = onLongClick // 🔥 Long press для selection onLongClick =
onLongClick // 🔥
// Long
// press
// для
// selection
) )
}, },
timeContent = { timeContent = {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment =
horizontalArrangement = Arrangement.spacedBy(2.dp) Alignment
.CenterVertically,
horizontalArrangement =
Arrangement
.spacedBy(
2.dp
)
) { ) {
Text( Text(
text = timeFormat.format(message.timestamp), text =
timeFormat
.format(
message.timestamp
),
color = timeColor, color = timeColor,
fontSize = 11.sp, fontSize = 11.sp,
fontStyle = fontStyle =
androidx.compose.ui.text.font.FontStyle.Italic androidx.compose
.ui
.text
.font
.FontStyle
.Italic
) )
if (message.isOutgoing) { if (message.isOutgoing) {
val displayStatus = val displayStatus =
if (isSavedMessages) MessageStatus.READ if (isSavedMessages
else message.status )
MessageStatus
.READ
else
message.status
AnimatedMessageStatus( AnimatedMessageStatus(
status = displayStatus, status =
timeColor = timeColor, displayStatus,
timestamp = message.timestamp.time, timeColor =
onRetry = onRetry, timeColor,
onDelete = onDelete timestamp =
message.timestamp
.time,
onRetry =
onRetry,
onDelete =
onDelete
) )
} }
} }
}, },
fillWidth = true // 🔥 Время справа с учётом ширины ReplyBubble fillWidth = true // 🔥 Время справа с учётом
// ширины ReplyBubble
) )
} else if (!hasOnlyMedia && !hasImageWithCaption && message.text.isNotEmpty()) { } else if (!hasOnlyMedia &&
// Telegram-style: текст + время с автоматическим переносом !hasImageWithCaption &&
message.text.isNotEmpty()
) {
// Telegram-style: текст + время с автоматическим
// переносом
TelegramStyleMessageContent( TelegramStyleMessageContent(
textContent = { textContent = {
AppleEmojiText( AppleEmojiText(
@@ -715,31 +846,61 @@ fun MessageBubble(
color = textColor, color = textColor,
fontSize = 17.sp, fontSize = 17.sp,
linkColor = linkColor, linkColor = linkColor,
onLongClick = onLongClick // 🔥 Long press для selection onLongClick =
onLongClick // 🔥
// Long
// press
// для
// selection
) )
}, },
timeContent = { timeContent = {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment =
horizontalArrangement = Arrangement.spacedBy(2.dp) Alignment
.CenterVertically,
horizontalArrangement =
Arrangement
.spacedBy(
2.dp
)
) { ) {
Text( Text(
text = timeFormat.format(message.timestamp), text =
timeFormat
.format(
message.timestamp
),
color = timeColor, color = timeColor,
fontSize = 11.sp, fontSize = 11.sp,
fontStyle = fontStyle =
androidx.compose.ui.text.font.FontStyle.Italic androidx.compose
.ui
.text
.font
.FontStyle
.Italic
) )
if (message.isOutgoing) { if (message.isOutgoing) {
val displayStatus = val displayStatus =
if (isSavedMessages) MessageStatus.READ if (isSavedMessages
else message.status )
MessageStatus
.READ
else
message.status
AnimatedMessageStatus( AnimatedMessageStatus(
status = displayStatus, status =
timeColor = timeColor, displayStatus,
timestamp = message.timestamp.time, timeColor =
onRetry = onRetry, timeColor,
onDelete = onDelete timestamp =
message.timestamp
.time,
onRetry =
onRetry,
onDelete =
onDelete
) )
} }
} }
@@ -762,7 +923,9 @@ fun AnimatedMessageStatus(
onDelete: () -> Unit = {} onDelete: () -> Unit = {}
) { ) {
val isTimedOut = val isTimedOut =
status == MessageStatus.SENDING && timestamp > 0 && !isMessageDeliveredByTime(timestamp) status == MessageStatus.SENDING &&
timestamp > 0 &&
!isMessageDeliveredByTime(timestamp)
val effectiveStatus = if (isTimedOut) MessageStatus.ERROR else status val effectiveStatus = if (isTimedOut) MessageStatus.ERROR else status
val targetColor = val targetColor =
@@ -827,13 +990,18 @@ fun AnimatedMessageStatus(
.scale(scale) .scale(scale)
.then( .then(
if (currentStatus == MessageStatus.ERROR) { if (currentStatus == MessageStatus.ERROR) {
Modifier.clickable { showErrorMenu = true } Modifier.clickable {
showErrorMenu = true
}
} else Modifier } else Modifier
) )
) )
} }
DropdownMenu(expanded = showErrorMenu, onDismissRequest = { showErrorMenu = false }) { DropdownMenu(
expanded = showErrorMenu,
onDismissRequest = { showErrorMenu = false }
) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Retry") }, text = { Text("Retry") },
onClick = { onClick = {
@@ -906,19 +1074,22 @@ fun ReplyBubble(
if (imageAttachment != null && imageAttachment.preview.isNotEmpty()) { if (imageAttachment != null && imageAttachment.preview.isNotEmpty()) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
// Получаем blurhash из preview (может быть в формате "tag::blurhash") // Получаем blurhash из preview (может быть в формате
// "tag::blurhash")
val blurhash = val blurhash =
if (imageAttachment.preview.contains("::")) { if (imageAttachment.preview.contains("::")) {
imageAttachment.preview.substringAfter("::") imageAttachment.preview.substringAfter("::")
} else if (!imageAttachment.preview.startsWith("http") && } else if (!imageAttachment.preview.startsWith(
imageAttachment.preview.length < 50 "http"
) && imageAttachment.preview.length < 50
) { ) {
imageAttachment.preview imageAttachment.preview
} else { } else {
"" ""
} }
if (blurhash.isNotEmpty()) { if (blurhash.isNotEmpty()) {
blurPreviewBitmap = BlurHash.decode(blurhash, 36, 36) blurPreviewBitmap =
BlurHash.decode(blurhash, 36, 36)
} }
} catch (e: Exception) { } catch (e: Exception) {
// Ignore blurhash decode errors // Ignore blurhash decode errors
@@ -937,12 +1108,23 @@ fun ReplyBubble(
val decoded = val decoded =
try { try {
val cleanBase64 = val cleanBase64 =
if (imageAttachment.blob.contains(",")) { if (imageAttachment.blob
imageAttachment.blob.substringAfter(",") .contains(
","
)
) {
imageAttachment.blob
.substringAfter(
","
)
} else { } else {
imageAttachment.blob imageAttachment.blob
} }
val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT) val decodedBytes =
Base64.decode(
cleanBase64,
Base64.DEFAULT
)
BitmapFactory.decodeByteArray( BitmapFactory.decodeByteArray(
decodedBytes, decodedBytes,
0, 0,
@@ -970,12 +1152,20 @@ fun ReplyBubble(
val decoded = val decoded =
try { try {
val cleanBase64 = val cleanBase64 =
if (localBlob.contains(",")) { if (localBlob.contains(",")
localBlob.substringAfter(",") ) {
localBlob
.substringAfter(
","
)
} else { } else {
localBlob localBlob
} }
val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT) val decodedBytes =
Base64.decode(
cleanBase64,
Base64.DEFAULT
)
BitmapFactory.decodeByteArray( BitmapFactory.decodeByteArray(
decodedBytes, decodedBytes,
0, 0,
@@ -986,8 +1176,7 @@ fun ReplyBubble(
} }
imageBitmap = decoded imageBitmap = decoded
} }
} catch (e: Exception) { } catch (e: Exception) {}
}
} }
} }
} }
@@ -1033,7 +1222,9 @@ fun ReplyBubble(
when { when {
replyData.text.isNotEmpty() -> replyData.text replyData.text.isNotEmpty() -> replyData.text
hasImage -> "Photo" hasImage -> "Photo"
replyData.attachments.any { it.type == AttachmentType.FILE } -> "File" replyData.attachments.any {
it.type == AttachmentType.FILE
} -> "File"
else -> "..." else -> "..."
} }
@@ -1066,7 +1257,8 @@ fun ReplyBubble(
} else if (blurPreviewBitmap != null) { } else if (blurPreviewBitmap != null) {
// Blurhash preview если картинка не загружена // Blurhash preview если картинка не загружена
Image( Image(
bitmap = blurPreviewBitmap!!.asImageBitmap(), bitmap =
blurPreviewBitmap!!.asImageBitmap(),
contentDescription = "Photo preview", contentDescription = "Photo preview",
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
@@ -1080,7 +1272,10 @@ fun ReplyBubble(
Icon( Icon(
TablerIcons.Photo, TablerIcons.Photo,
contentDescription = null, contentDescription = null,
tint = Color.White.copy(alpha = 0.7f), tint =
Color.White.copy(
alpha = 0.7f
),
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
} }
@@ -1147,7 +1342,8 @@ private fun SkeletonBubble(
RoundedCornerShape( RoundedCornerShape(
topStart = 18.dp, topStart = 18.dp,
topEnd = 18.dp, topEnd = 18.dp,
bottomStart = if (isOutgoing) 18.dp else 6.dp, bottomStart =
if (isOutgoing) 18.dp else 6.dp,
bottomEnd = if (isOutgoing) 6.dp else 18.dp bottomEnd = if (isOutgoing) 6.dp else 18.dp
) )
) )
@@ -1284,10 +1480,12 @@ fun ReplyImagePreview(
if (attachment.preview.isNotEmpty()) { if (attachment.preview.isNotEmpty()) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
// Получаем blurhash из preview (может быть в формате "tag::blurhash") // Получаем blurhash из preview (может быть в формате
// "tag::blurhash")
val blurhash = val blurhash =
if (attachment.preview.contains("::")) { if (attachment.preview.contains("::")) {
attachment.preview.split("::").lastOrNull() ?: "" attachment.preview.split("::").lastOrNull()
?: ""
} else { } else {
attachment.preview attachment.preview
} }
@@ -1311,12 +1509,23 @@ fun ReplyImagePreview(
val decoded = val decoded =
try { try {
val cleanBase64 = val cleanBase64 =
if (attachment.blob.contains(",")) { if (attachment.blob
attachment.blob.substringAfter(",") .contains(
","
)
) {
attachment.blob
.substringAfter(
","
)
} else { } else {
attachment.blob attachment.blob
} }
val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT) val decodedBytes =
Base64.decode(
cleanBase64,
Base64.DEFAULT
)
BitmapFactory.decodeByteArray( BitmapFactory.decodeByteArray(
decodedBytes, decodedBytes,
0, 0,
@@ -1344,12 +1553,20 @@ fun ReplyImagePreview(
val decoded = val decoded =
try { try {
val cleanBase64 = val cleanBase64 =
if (localBlob.contains(",")) { if (localBlob.contains(",")
localBlob.substringAfter(",") ) {
localBlob
.substringAfter(
","
)
} else { } else {
localBlob localBlob
} }
val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT) val decodedBytes =
Base64.decode(
cleanBase64,
Base64.DEFAULT
)
BitmapFactory.decodeByteArray( BitmapFactory.decodeByteArray(
decodedBytes, decodedBytes,
0, 0,
@@ -1360,8 +1577,7 @@ fun ReplyImagePreview(
} }
fullImageBitmap = decoded fullImageBitmap = decoded
} }
} catch (e: Exception) { } catch (e: Exception) {}
}
} }
} }
} }
@@ -1389,7 +1605,10 @@ fun ReplyImagePreview(
) )
} else { } else {
// Placeholder с иконкой только если нет ничего // Placeholder с иконкой только если нет ничего
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Icon( Icon(
TablerIcons.Photo, TablerIcons.Photo,
contentDescription = null, contentDescription = null,