Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -4,10 +4,8 @@ import android.content.Context
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.horizontalDrag
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.clipToBounds
|
||||
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.positionChange
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
@@ -1694,6 +1693,8 @@ fun SwipeableDialogItem(
|
||||
.background(backgroundColor)
|
||||
.pointerInput(Unit) {
|
||||
val velocityTracker = VelocityTracker()
|
||||
val touchSlop = viewConfiguration.touchSlop
|
||||
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
|
||||
@@ -1701,22 +1702,58 @@ fun SwipeableDialogItem(
|
||||
if (isDrawerOpen) return@awaitEachGesture
|
||||
|
||||
velocityTracker.resetTracking()
|
||||
var started = false
|
||||
try {
|
||||
horizontalDrag(down.id) { change ->
|
||||
val dragAmount = change.positionChange().x
|
||||
var totalDragX = 0f
|
||||
var totalDragY = 0f
|
||||
var passedSlop = false
|
||||
var claimed = false
|
||||
|
||||
// First movement determines direction
|
||||
if (!started) {
|
||||
// Swipe right with actions closed — let drawer handle it
|
||||
if (dragAmount > 0 && offsetX == 0f) {
|
||||
return@horizontalDrag
|
||||
}
|
||||
started = true
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
?: break
|
||||
if (change.changedToUpIgnoreConsumed()) break
|
||||
|
||||
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()
|
||||
change.consume()
|
||||
}
|
||||
|
||||
val newOffset = offsetX + dragAmount
|
||||
// Horizontal right swipe with buttons open — close them
|
||||
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)
|
||||
velocityTracker.addPosition(
|
||||
change.uptimeMillis,
|
||||
@@ -1724,21 +1761,31 @@ fun SwipeableDialogItem(
|
||||
)
|
||||
change.consume()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
offsetX = 0f
|
||||
}
|
||||
|
||||
if (started) {
|
||||
// Snap animation
|
||||
if (claimed) {
|
||||
val velocity = velocityTracker.calculateVelocity().x
|
||||
// Telegram-like: fling left (-velocity) OR dragged past 1/3
|
||||
val shouldOpen = velocity < -300f ||
|
||||
kotlin.math.abs(offsetX) > swipeWidthPx / 3
|
||||
if (shouldOpen) {
|
||||
offsetX = -swipeWidthPx
|
||||
} else {
|
||||
when {
|
||||
// Rightward fling — always close
|
||||
velocity > 150f -> {
|
||||
offsetX = 0f
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import kotlin.math.abs
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.vector.ImageVector
|
||||
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.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.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
@@ -56,6 +50,7 @@ import compose.icons.TablerIcons
|
||||
import compose.icons.tablericons.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -65,10 +60,9 @@ import kotlinx.coroutines.withContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* Telegram-style layout для текста сообщения с временем.
|
||||
* Если текст + время помещаются в одну строку - располагает их рядом.
|
||||
* Если текст длинный и переносится - время встаёт в правый нижний угол,
|
||||
* под последней строкой текста (как в Telegram).
|
||||
* Telegram-style layout для текста сообщения с временем. Если текст + время помещаются в одну
|
||||
* строку - располагает их рядом. Если текст длинный и переносится - время встаёт в правый нижний
|
||||
* угол, под последней строкой текста (как в Telegram).
|
||||
*
|
||||
* @param textContent Composable с текстом сообщения
|
||||
* @param timeContent Composable с временем и статусом
|
||||
@@ -94,13 +88,16 @@ fun TelegramStyleMessageContent(
|
||||
},
|
||||
modifier = modifier
|
||||
) { 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 timeMeasurable = measurables[1]
|
||||
|
||||
// Измеряем время с минимальными 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)
|
||||
@@ -120,13 +117,15 @@ fun TelegramStyleMessageContent(
|
||||
// 🔥 Для caption - занимаем всю доступную ширину
|
||||
val w = constraints.maxWidth
|
||||
|
||||
// 🔥 ИСПРАВЛЕНИЕ: Проверяем помещается ли текст + время в одну строку
|
||||
// 🔥 ИСПРАВЛЕНИЕ: Проверяем помещается ли текст + время в одну
|
||||
// строку
|
||||
val availableForTime = w - textWidth - spacingPx
|
||||
if (availableForTime >= timeWidth) {
|
||||
// Текст и время помещаются - время справа на одной линии
|
||||
val h = maxOf(textPlaceable.height, timePlaceable.height)
|
||||
val tX = w - timeWidth // Время справа
|
||||
val tY = h - timePlaceable.height // Время внизу строки (выровнено по baseline)
|
||||
val tY = h - timePlaceable.height // Время внизу строки
|
||||
// (выровнено по baseline)
|
||||
LayoutResult(w, h, tX, tY)
|
||||
} else {
|
||||
// Текст длинный - время на новой строке справа внизу
|
||||
@@ -323,9 +322,13 @@ fun MessageBubble(
|
||||
topEnd = TelegramBubbleSpec.bubbleRadius,
|
||||
bottomStart =
|
||||
if (message.isOutgoing) TelegramBubbleSpec.bubbleRadius
|
||||
else (if (showTail) TelegramBubbleSpec.nearRadius else TelegramBubbleSpec.bubbleRadius),
|
||||
else
|
||||
(if (showTail) TelegramBubbleSpec.nearRadius
|
||||
else TelegramBubbleSpec.bubbleRadius),
|
||||
bottomEnd =
|
||||
if (message.isOutgoing) (if (showTail) TelegramBubbleSpec.nearRadius else TelegramBubbleSpec.bubbleRadius)
|
||||
if (message.isOutgoing)
|
||||
(if (showTail) TelegramBubbleSpec.nearRadius
|
||||
else TelegramBubbleSpec.bubbleRadius)
|
||||
else TelegramBubbleSpec.bubbleRadius
|
||||
)
|
||||
}
|
||||
@@ -336,7 +339,8 @@ fun MessageBubble(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().pointerInput(Unit) {
|
||||
// 🔥 Простой горизонтальный свайп для reply
|
||||
// Используем detectHorizontalDragGestures который лучше работает со скроллом
|
||||
// Используем detectHorizontalDragGestures который лучше работает со
|
||||
// скроллом
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = {},
|
||||
onDragEnd = {
|
||||
@@ -346,15 +350,14 @@ fun MessageBubble(
|
||||
}
|
||||
swipeOffset = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
swipeOffset = 0f
|
||||
},
|
||||
onDragCancel = { swipeOffset = 0f },
|
||||
onHorizontalDrag = { change, dragAmount ->
|
||||
// Только свайп влево (отрицательное значение)
|
||||
if (dragAmount < 0 || swipeOffset < 0) {
|
||||
change.consume()
|
||||
val newOffset = swipeOffset + dragAmount
|
||||
swipeOffset = newOffset.coerceIn(-maxSwipe, 0f)
|
||||
swipeOffset =
|
||||
newOffset.coerceIn(-maxSwipe, 0f)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -363,7 +366,9 @@ fun MessageBubble(
|
||||
// 🔥 Reply icon - справа, появляется при свайпе влево
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.align(Alignment.CenterEnd).padding(end = 16.dp).graphicsLayer {
|
||||
Modifier.align(Alignment.CenterEnd)
|
||||
.padding(end = 16.dp)
|
||||
.graphicsLayer {
|
||||
alpha = swipeProgress
|
||||
scaleX = swipeProgress
|
||||
scaleY = swipeProgress
|
||||
@@ -466,7 +471,9 @@ fun MessageBubble(
|
||||
message.text.isEmpty() &&
|
||||
message.replyData == null &&
|
||||
message.attachments.all {
|
||||
it.type == com.rosetta.messenger.network.AttachmentType.IMAGE
|
||||
it.type ==
|
||||
com.rosetta.messenger.network.AttachmentType
|
||||
.IMAGE
|
||||
}
|
||||
|
||||
// Фото + caption (как в Telegram)
|
||||
@@ -475,7 +482,9 @@ fun MessageBubble(
|
||||
message.text.isNotEmpty() &&
|
||||
message.replyData == null &&
|
||||
message.attachments.all {
|
||||
it.type == com.rosetta.messenger.network.AttachmentType.IMAGE
|
||||
it.type ==
|
||||
com.rosetta.messenger.network.AttachmentType
|
||||
.IMAGE
|
||||
}
|
||||
|
||||
// Для сообщений только с фото - минимальный padding и тонкий border
|
||||
@@ -495,25 +504,47 @@ fun MessageBubble(
|
||||
val maxPhotoWidth = TelegramBubbleSpec.maxPhotoWidth(screenWidthDp).dp
|
||||
|
||||
// Вычисляем ширину фото для ограничения пузырька
|
||||
val photoWidth = if (hasImageWithCaption || hasOnlyMedia) {
|
||||
val firstImage = message.attachments.firstOrNull {
|
||||
it.type == com.rosetta.messenger.network.AttachmentType.IMAGE
|
||||
val photoWidth =
|
||||
if (hasImageWithCaption || hasOnlyMedia) {
|
||||
val firstImage =
|
||||
message.attachments.firstOrNull {
|
||||
it.type ==
|
||||
com.rosetta.messenger.network
|
||||
.AttachmentType.IMAGE
|
||||
}
|
||||
if (firstImage != null && firstImage.width > 0 && firstImage.height > 0) {
|
||||
val ar = firstImage.width.toFloat() / firstImage.height.toFloat()
|
||||
val maxW = TelegramBubbleSpec.maxPhotoWidth(screenWidthDp).toFloat()
|
||||
if (firstImage != null &&
|
||||
firstImage.width > 0 &&
|
||||
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 h = w / ar
|
||||
|
||||
if (h > TelegramBubbleSpec.maxPhotoHeight) {
|
||||
h = TelegramBubbleSpec.maxPhotoHeight.toFloat()
|
||||
h =
|
||||
TelegramBubbleSpec.maxPhotoHeight
|
||||
.toFloat()
|
||||
w = h * ar
|
||||
}
|
||||
if (h < TelegramBubbleSpec.minPhotoHeight) {
|
||||
h = TelegramBubbleSpec.minPhotoHeight.toFloat()
|
||||
h =
|
||||
TelegramBubbleSpec.minPhotoHeight
|
||||
.toFloat()
|
||||
w = h * ar
|
||||
}
|
||||
w.coerceIn(TelegramBubbleSpec.minPhotoWidth.toFloat(), maxW).dp
|
||||
w.coerceIn(
|
||||
TelegramBubbleSpec.minPhotoWidth
|
||||
.toFloat(),
|
||||
maxW
|
||||
)
|
||||
.dp
|
||||
} else {
|
||||
maxPhotoWidth
|
||||
}
|
||||
@@ -521,8 +552,12 @@ fun MessageBubble(
|
||||
280.dp
|
||||
}
|
||||
|
||||
val bubbleWidthModifier = if (hasImageWithCaption || hasOnlyMedia) {
|
||||
Modifier.width(photoWidth) // 🔥 Фиксированная ширина = размер фото (убирает лишний отступ)
|
||||
val bubbleWidthModifier =
|
||||
if (hasImageWithCaption || hasOnlyMedia) {
|
||||
Modifier.width(
|
||||
photoWidth
|
||||
) // 🔥 Фиксированная ширина = размер фото (убирает лишний
|
||||
// отступ)
|
||||
} else {
|
||||
Modifier.widthIn(min = 60.dp, max = 280.dp)
|
||||
}
|
||||
@@ -539,7 +574,9 @@ fun MessageBubble(
|
||||
.combinedClickable(
|
||||
indication = null,
|
||||
interactionSource =
|
||||
remember { MutableInteractionSource() },
|
||||
remember {
|
||||
MutableInteractionSource()
|
||||
},
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
@@ -549,16 +586,26 @@ fun MessageBubble(
|
||||
Modifier.border(
|
||||
width = bubbleBorderWidth,
|
||||
color =
|
||||
if (message.isOutgoing) {
|
||||
Color.White.copy(alpha = 0.15f)
|
||||
if (message.isOutgoing
|
||||
) {
|
||||
Color.White
|
||||
.copy(
|
||||
alpha =
|
||||
0.15f
|
||||
)
|
||||
} else {
|
||||
if (isDarkTheme)
|
||||
Color.White.copy(
|
||||
alpha = 0.1f
|
||||
if (isDarkTheme
|
||||
)
|
||||
Color.White
|
||||
.copy(
|
||||
alpha =
|
||||
0.1f
|
||||
)
|
||||
else
|
||||
Color.Black.copy(
|
||||
alpha = 0.08f
|
||||
Color.Black
|
||||
.copy(
|
||||
alpha =
|
||||
0.08f
|
||||
)
|
||||
},
|
||||
shape = bubbleShape
|
||||
@@ -583,7 +630,8 @@ fun MessageBubble(
|
||||
// 📎 Attachments (IMAGE, FILE, AVATAR)
|
||||
if (message.attachments.isNotEmpty()) {
|
||||
val attachmentDisplayStatus =
|
||||
if (isSavedMessages) MessageStatus.READ else message.status
|
||||
if (isSavedMessages) MessageStatus.READ
|
||||
else message.status
|
||||
MessageAttachments(
|
||||
attachments = message.attachments,
|
||||
chachaKey = message.chachaKey,
|
||||
@@ -596,28 +644,42 @@ fun MessageBubble(
|
||||
avatarRepository = avatarRepository,
|
||||
currentUserPublicKey = currentUserPublicKey,
|
||||
hasCaption = hasImageWithCaption,
|
||||
showTail = showTail, // Передаём для формы пузырька
|
||||
showTail = showTail, // Передаём для формы
|
||||
// пузырька
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
|
||||
// 🖼️ Caption под фото (Telegram-style)
|
||||
// Отступ от фото = 9dp, слева = 11dp (исходящие) / 13dp (входящие)
|
||||
// Отступ от фото = 9dp, слева = 11dp (исходящие) / 13dp
|
||||
// (входящие)
|
||||
if (hasImageWithCaption) {
|
||||
val captionPaddingStart = if (message.isOutgoing)
|
||||
TelegramBubbleSpec.captionPaddingStartOutgoing
|
||||
val captionPaddingStart =
|
||||
if (message.isOutgoing)
|
||||
TelegramBubbleSpec
|
||||
.captionPaddingStartOutgoing
|
||||
else
|
||||
TelegramBubbleSpec.captionPaddingStartIncoming
|
||||
TelegramBubbleSpec
|
||||
.captionPaddingStartIncoming
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth() // 🔥 Растягиваем на ширину фото для правильного позиционирования времени
|
||||
Modifier.fillMaxWidth() // 🔥
|
||||
// Растягиваем на ширину
|
||||
// фото для правильного
|
||||
// позиционирования времени
|
||||
.background(bubbleColor)
|
||||
.padding(
|
||||
start = captionPaddingStart,
|
||||
end = TelegramBubbleSpec.captionPaddingEnd,
|
||||
top = TelegramBubbleSpec.captionPaddingTop,
|
||||
bottom = TelegramBubbleSpec.captionPaddingBottom
|
||||
start =
|
||||
captionPaddingStart,
|
||||
end =
|
||||
TelegramBubbleSpec
|
||||
.captionPaddingEnd,
|
||||
top =
|
||||
TelegramBubbleSpec
|
||||
.captionPaddingTop,
|
||||
bottom =
|
||||
TelegramBubbleSpec
|
||||
.captionPaddingBottom
|
||||
)
|
||||
) {
|
||||
TelegramStyleMessageContent(
|
||||
@@ -626,48 +688,82 @@ fun MessageBubble(
|
||||
text = message.text,
|
||||
color = textColor,
|
||||
fontSize = 16.sp,
|
||||
linkColor = linkColor,
|
||||
onLongClick = onLongClick // 🔥 Long press для selection
|
||||
linkColor =
|
||||
linkColor,
|
||||
onLongClick =
|
||||
onLongClick // 🔥 Long press для selection
|
||||
)
|
||||
},
|
||||
timeContent = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
verticalAlignment =
|
||||
Alignment
|
||||
.CenterVertically,
|
||||
horizontalArrangement =
|
||||
Arrangement
|
||||
.spacedBy(
|
||||
2.dp
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = timeFormat.format(message.timestamp),
|
||||
color = timeColor,
|
||||
fontSize = 11.sp,
|
||||
text =
|
||||
timeFormat
|
||||
.format(
|
||||
message.timestamp
|
||||
),
|
||||
color =
|
||||
timeColor,
|
||||
fontSize =
|
||||
11.sp,
|
||||
fontStyle =
|
||||
androidx.compose.ui.text.font.FontStyle
|
||||
androidx.compose
|
||||
.ui
|
||||
.text
|
||||
.font
|
||||
.FontStyle
|
||||
.Italic
|
||||
)
|
||||
if (message.isOutgoing) {
|
||||
if (message.isOutgoing
|
||||
) {
|
||||
val displayStatus =
|
||||
if (isSavedMessages) MessageStatus.READ
|
||||
else message.status
|
||||
if (isSavedMessages
|
||||
)
|
||||
MessageStatus
|
||||
.READ
|
||||
else
|
||||
message.status
|
||||
AnimatedMessageStatus(
|
||||
status = displayStatus,
|
||||
timeColor = timeColor,
|
||||
timestamp = message.timestamp.time,
|
||||
onRetry = onRetry,
|
||||
onDelete = onDelete
|
||||
status =
|
||||
displayStatus,
|
||||
timeColor =
|
||||
timeColor,
|
||||
timestamp =
|
||||
message.timestamp
|
||||
.time,
|
||||
onRetry =
|
||||
onRetry,
|
||||
onDelete =
|
||||
onDelete
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
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))
|
||||
}
|
||||
|
||||
// Если есть reply - Telegram-style layout
|
||||
if (message.replyData != null && message.text.isNotEmpty()) {
|
||||
if (message.replyData != null && message.text.isNotEmpty()
|
||||
) {
|
||||
TelegramStyleMessageContent(
|
||||
textContent = {
|
||||
AppleEmojiText(
|
||||
@@ -675,39 +771,74 @@ fun MessageBubble(
|
||||
color = textColor,
|
||||
fontSize = 17.sp,
|
||||
linkColor = linkColor,
|
||||
onLongClick = onLongClick // 🔥 Long press для selection
|
||||
onLongClick =
|
||||
onLongClick // 🔥
|
||||
// Long
|
||||
// press
|
||||
// для
|
||||
// selection
|
||||
)
|
||||
},
|
||||
timeContent = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
verticalAlignment =
|
||||
Alignment
|
||||
.CenterVertically,
|
||||
horizontalArrangement =
|
||||
Arrangement
|
||||
.spacedBy(
|
||||
2.dp
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = timeFormat.format(message.timestamp),
|
||||
text =
|
||||
timeFormat
|
||||
.format(
|
||||
message.timestamp
|
||||
),
|
||||
color = timeColor,
|
||||
fontSize = 11.sp,
|
||||
fontStyle =
|
||||
androidx.compose.ui.text.font.FontStyle.Italic
|
||||
androidx.compose
|
||||
.ui
|
||||
.text
|
||||
.font
|
||||
.FontStyle
|
||||
.Italic
|
||||
)
|
||||
if (message.isOutgoing) {
|
||||
val displayStatus =
|
||||
if (isSavedMessages) MessageStatus.READ
|
||||
else message.status
|
||||
if (isSavedMessages
|
||||
)
|
||||
MessageStatus
|
||||
.READ
|
||||
else
|
||||
message.status
|
||||
AnimatedMessageStatus(
|
||||
status = displayStatus,
|
||||
timeColor = timeColor,
|
||||
timestamp = message.timestamp.time,
|
||||
onRetry = onRetry,
|
||||
onDelete = onDelete
|
||||
status =
|
||||
displayStatus,
|
||||
timeColor =
|
||||
timeColor,
|
||||
timestamp =
|
||||
message.timestamp
|
||||
.time,
|
||||
onRetry =
|
||||
onRetry,
|
||||
onDelete =
|
||||
onDelete
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
fillWidth = true // 🔥 Время справа с учётом ширины ReplyBubble
|
||||
fillWidth = true // 🔥 Время справа с учётом
|
||||
// ширины ReplyBubble
|
||||
)
|
||||
} else if (!hasOnlyMedia && !hasImageWithCaption && message.text.isNotEmpty()) {
|
||||
// Telegram-style: текст + время с автоматическим переносом
|
||||
} else if (!hasOnlyMedia &&
|
||||
!hasImageWithCaption &&
|
||||
message.text.isNotEmpty()
|
||||
) {
|
||||
// Telegram-style: текст + время с автоматическим
|
||||
// переносом
|
||||
TelegramStyleMessageContent(
|
||||
textContent = {
|
||||
AppleEmojiText(
|
||||
@@ -715,31 +846,61 @@ fun MessageBubble(
|
||||
color = textColor,
|
||||
fontSize = 17.sp,
|
||||
linkColor = linkColor,
|
||||
onLongClick = onLongClick // 🔥 Long press для selection
|
||||
onLongClick =
|
||||
onLongClick // 🔥
|
||||
// Long
|
||||
// press
|
||||
// для
|
||||
// selection
|
||||
)
|
||||
},
|
||||
timeContent = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
verticalAlignment =
|
||||
Alignment
|
||||
.CenterVertically,
|
||||
horizontalArrangement =
|
||||
Arrangement
|
||||
.spacedBy(
|
||||
2.dp
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = timeFormat.format(message.timestamp),
|
||||
text =
|
||||
timeFormat
|
||||
.format(
|
||||
message.timestamp
|
||||
),
|
||||
color = timeColor,
|
||||
fontSize = 11.sp,
|
||||
fontStyle =
|
||||
androidx.compose.ui.text.font.FontStyle.Italic
|
||||
androidx.compose
|
||||
.ui
|
||||
.text
|
||||
.font
|
||||
.FontStyle
|
||||
.Italic
|
||||
)
|
||||
if (message.isOutgoing) {
|
||||
val displayStatus =
|
||||
if (isSavedMessages) MessageStatus.READ
|
||||
else message.status
|
||||
if (isSavedMessages
|
||||
)
|
||||
MessageStatus
|
||||
.READ
|
||||
else
|
||||
message.status
|
||||
AnimatedMessageStatus(
|
||||
status = displayStatus,
|
||||
timeColor = timeColor,
|
||||
timestamp = message.timestamp.time,
|
||||
onRetry = onRetry,
|
||||
onDelete = onDelete
|
||||
status =
|
||||
displayStatus,
|
||||
timeColor =
|
||||
timeColor,
|
||||
timestamp =
|
||||
message.timestamp
|
||||
.time,
|
||||
onRetry =
|
||||
onRetry,
|
||||
onDelete =
|
||||
onDelete
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -762,7 +923,9 @@ fun AnimatedMessageStatus(
|
||||
onDelete: () -> Unit = {}
|
||||
) {
|
||||
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 targetColor =
|
||||
@@ -827,13 +990,18 @@ fun AnimatedMessageStatus(
|
||||
.scale(scale)
|
||||
.then(
|
||||
if (currentStatus == MessageStatus.ERROR) {
|
||||
Modifier.clickable { showErrorMenu = true }
|
||||
Modifier.clickable {
|
||||
showErrorMenu = true
|
||||
}
|
||||
} else Modifier
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(expanded = showErrorMenu, onDismissRequest = { showErrorMenu = false }) {
|
||||
DropdownMenu(
|
||||
expanded = showErrorMenu,
|
||||
onDismissRequest = { showErrorMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Retry") },
|
||||
onClick = {
|
||||
@@ -906,19 +1074,22 @@ fun ReplyBubble(
|
||||
if (imageAttachment != null && imageAttachment.preview.isNotEmpty()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Получаем blurhash из preview (может быть в формате "tag::blurhash")
|
||||
// Получаем blurhash из preview (может быть в формате
|
||||
// "tag::blurhash")
|
||||
val blurhash =
|
||||
if (imageAttachment.preview.contains("::")) {
|
||||
imageAttachment.preview.substringAfter("::")
|
||||
} else if (!imageAttachment.preview.startsWith("http") &&
|
||||
imageAttachment.preview.length < 50
|
||||
} else if (!imageAttachment.preview.startsWith(
|
||||
"http"
|
||||
) && imageAttachment.preview.length < 50
|
||||
) {
|
||||
imageAttachment.preview
|
||||
} else {
|
||||
""
|
||||
}
|
||||
if (blurhash.isNotEmpty()) {
|
||||
blurPreviewBitmap = BlurHash.decode(blurhash, 36, 36)
|
||||
blurPreviewBitmap =
|
||||
BlurHash.decode(blurhash, 36, 36)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ignore blurhash decode errors
|
||||
@@ -937,12 +1108,23 @@ fun ReplyBubble(
|
||||
val decoded =
|
||||
try {
|
||||
val cleanBase64 =
|
||||
if (imageAttachment.blob.contains(",")) {
|
||||
imageAttachment.blob.substringAfter(",")
|
||||
if (imageAttachment.blob
|
||||
.contains(
|
||||
","
|
||||
)
|
||||
) {
|
||||
imageAttachment.blob
|
||||
.substringAfter(
|
||||
","
|
||||
)
|
||||
} else {
|
||||
imageAttachment.blob
|
||||
}
|
||||
val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT)
|
||||
val decodedBytes =
|
||||
Base64.decode(
|
||||
cleanBase64,
|
||||
Base64.DEFAULT
|
||||
)
|
||||
BitmapFactory.decodeByteArray(
|
||||
decodedBytes,
|
||||
0,
|
||||
@@ -970,12 +1152,20 @@ fun ReplyBubble(
|
||||
val decoded =
|
||||
try {
|
||||
val cleanBase64 =
|
||||
if (localBlob.contains(",")) {
|
||||
localBlob.substringAfter(",")
|
||||
if (localBlob.contains(",")
|
||||
) {
|
||||
localBlob
|
||||
.substringAfter(
|
||||
","
|
||||
)
|
||||
} else {
|
||||
localBlob
|
||||
}
|
||||
val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT)
|
||||
val decodedBytes =
|
||||
Base64.decode(
|
||||
cleanBase64,
|
||||
Base64.DEFAULT
|
||||
)
|
||||
BitmapFactory.decodeByteArray(
|
||||
decodedBytes,
|
||||
0,
|
||||
@@ -986,8 +1176,7 @@ fun ReplyBubble(
|
||||
}
|
||||
imageBitmap = decoded
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1033,7 +1222,9 @@ fun ReplyBubble(
|
||||
when {
|
||||
replyData.text.isNotEmpty() -> replyData.text
|
||||
hasImage -> "Photo"
|
||||
replyData.attachments.any { it.type == AttachmentType.FILE } -> "File"
|
||||
replyData.attachments.any {
|
||||
it.type == AttachmentType.FILE
|
||||
} -> "File"
|
||||
else -> "..."
|
||||
}
|
||||
|
||||
@@ -1066,7 +1257,8 @@ fun ReplyBubble(
|
||||
} else if (blurPreviewBitmap != null) {
|
||||
// Blurhash preview если картинка не загружена
|
||||
Image(
|
||||
bitmap = blurPreviewBitmap!!.asImageBitmap(),
|
||||
bitmap =
|
||||
blurPreviewBitmap!!.asImageBitmap(),
|
||||
contentDescription = "Photo preview",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
@@ -1080,7 +1272,10 @@ fun ReplyBubble(
|
||||
Icon(
|
||||
TablerIcons.Photo,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
tint =
|
||||
Color.White.copy(
|
||||
alpha = 0.7f
|
||||
),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
@@ -1147,7 +1342,8 @@ private fun SkeletonBubble(
|
||||
RoundedCornerShape(
|
||||
topStart = 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
|
||||
)
|
||||
)
|
||||
@@ -1284,10 +1480,12 @@ fun ReplyImagePreview(
|
||||
if (attachment.preview.isNotEmpty()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Получаем blurhash из preview (может быть в формате "tag::blurhash")
|
||||
// Получаем blurhash из preview (может быть в формате
|
||||
// "tag::blurhash")
|
||||
val blurhash =
|
||||
if (attachment.preview.contains("::")) {
|
||||
attachment.preview.split("::").lastOrNull() ?: ""
|
||||
attachment.preview.split("::").lastOrNull()
|
||||
?: ""
|
||||
} else {
|
||||
attachment.preview
|
||||
}
|
||||
@@ -1311,12 +1509,23 @@ fun ReplyImagePreview(
|
||||
val decoded =
|
||||
try {
|
||||
val cleanBase64 =
|
||||
if (attachment.blob.contains(",")) {
|
||||
attachment.blob.substringAfter(",")
|
||||
if (attachment.blob
|
||||
.contains(
|
||||
","
|
||||
)
|
||||
) {
|
||||
attachment.blob
|
||||
.substringAfter(
|
||||
","
|
||||
)
|
||||
} else {
|
||||
attachment.blob
|
||||
}
|
||||
val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT)
|
||||
val decodedBytes =
|
||||
Base64.decode(
|
||||
cleanBase64,
|
||||
Base64.DEFAULT
|
||||
)
|
||||
BitmapFactory.decodeByteArray(
|
||||
decodedBytes,
|
||||
0,
|
||||
@@ -1344,12 +1553,20 @@ fun ReplyImagePreview(
|
||||
val decoded =
|
||||
try {
|
||||
val cleanBase64 =
|
||||
if (localBlob.contains(",")) {
|
||||
localBlob.substringAfter(",")
|
||||
if (localBlob.contains(",")
|
||||
) {
|
||||
localBlob
|
||||
.substringAfter(
|
||||
","
|
||||
)
|
||||
} else {
|
||||
localBlob
|
||||
}
|
||||
val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT)
|
||||
val decodedBytes =
|
||||
Base64.decode(
|
||||
cleanBase64,
|
||||
Base64.DEFAULT
|
||||
)
|
||||
BitmapFactory.decodeByteArray(
|
||||
decodedBytes,
|
||||
0,
|
||||
@@ -1360,8 +1577,7 @@ fun ReplyImagePreview(
|
||||
}
|
||||
fullImageBitmap = decoded
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1389,7 +1605,10 @@ fun ReplyImagePreview(
|
||||
)
|
||||
} else {
|
||||
// Placeholder с иконкой только если нет ничего
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
TablerIcons.Photo,
|
||||
contentDescription = null,
|
||||
|
||||
Reference in New Issue
Block a user