feat: polish chat media sizing and album editing
- size single photo and video bubbles by real aspect ratio - move private contact and blocked badges into the chat info sheet - add drag reorder, inline remove, and crop/rotate tools to album preview - tighten circle playback reset behavior at the end
This commit is contained in:
@@ -4,6 +4,8 @@ package ru.daemonlord.messenger.ui.chat
|
||||
|
||||
import android.Manifest
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
@@ -24,6 +26,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
@@ -101,6 +104,7 @@ import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
@@ -122,6 +126,8 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
@@ -1666,6 +1672,42 @@ private fun ChatScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (isPrivateChat && state.isCounterpartRelationshipResolved) {
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (state.isCounterpartContact) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.chat_private_info_contact_badge),
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.isCounterpartBlocked) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = MaterialTheme.colorScheme.error.copy(alpha = 0.16f),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.chat_private_info_blocked_badge),
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
@@ -3052,6 +3094,8 @@ private fun MediaBatchPreviewSheet(
|
||||
val scope = rememberCoroutineScope()
|
||||
val currentIndex = pagerState.currentPage.coerceIn(0, (mediaItems.size - 1).coerceAtLeast(0))
|
||||
val currentItem = mediaItems.getOrNull(currentIndex)
|
||||
var draggingIndex by remember { mutableStateOf<Int?>(null) }
|
||||
var dragOffsetPx by remember { mutableStateOf(0f) }
|
||||
|
||||
LaunchedEffect(mediaItems.size) {
|
||||
if (mediaItems.isEmpty()) {
|
||||
@@ -3081,31 +3125,31 @@ private fun MediaBatchPreviewSheet(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (currentIndex > 0) {
|
||||
onUpdateItems(mediaItems.move(currentIndex, currentIndex - 1))
|
||||
}
|
||||
},
|
||||
enabled = currentIndex > 0,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(id = R.string.chat_media_preview_move_left),
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (currentIndex < mediaItems.lastIndex) {
|
||||
onUpdateItems(mediaItems.move(currentIndex, currentIndex + 1))
|
||||
}
|
||||
},
|
||||
enabled = currentIndex < mediaItems.lastIndex,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = stringResource(id = R.string.chat_media_preview_move_right),
|
||||
)
|
||||
if (currentItem != null && currentItem.isEditableStaticImage) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
currentItem.rotateClockwise()?.let { updated ->
|
||||
onUpdateItems(mediaItems.updatedAt(currentIndex, updated))
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Edit,
|
||||
contentDescription = stringResource(id = R.string.chat_media_preview_rotate),
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
currentItem.cropCenterSquare()?.let { updated ->
|
||||
onUpdateItems(mediaItems.updatedAt(currentIndex, updated))
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Image,
|
||||
contentDescription = stringResource(id = R.string.chat_media_preview_crop_square),
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
@@ -3136,6 +3180,20 @@ private fun MediaBatchPreviewSheet(
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = Color.Black.copy(alpha = 0.45f),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.chat_media_preview_count_badge, page + 1, mediaItems.size),
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
if (isVideo && previewUri != null) {
|
||||
MediaBatchVideoPreview(previewUri = previewUri)
|
||||
} else {
|
||||
@@ -3149,10 +3207,17 @@ private fun MediaBatchPreviewSheet(
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.chat_media_preview_reorder_hint),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(mediaItems.size, key = { index -> mediaItems[index].fileName + index }) { index ->
|
||||
val item = mediaItems[index]
|
||||
val isSelected = index == currentIndex
|
||||
val dragStepPx = with(LocalContext.current.resources.displayMetrics) { 88.dp.value * density }
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = if (isSelected) {
|
||||
@@ -3168,12 +3233,57 @@ private fun MediaBatchPreviewSheet(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.graphicsLayer {
|
||||
translationX = if (draggingIndex == index) dragOffsetPx else 0f
|
||||
}
|
||||
.clickable {
|
||||
scope.launch { pagerState.animateScrollToPage(index) }
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(mediaItems, index) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = {
|
||||
draggingIndex = index
|
||||
dragOffsetPx = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
draggingIndex = null
|
||||
dragOffsetPx = 0f
|
||||
},
|
||||
onDragEnd = {
|
||||
draggingIndex = null
|
||||
dragOffsetPx = 0f
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
if (draggingIndex == null) draggingIndex = index
|
||||
dragOffsetPx += dragAmount.x
|
||||
val activeIndex = draggingIndex ?: index
|
||||
when {
|
||||
dragOffsetPx > dragStepPx / 2f && activeIndex < mediaItems.lastIndex -> {
|
||||
onUpdateItems(mediaItems.move(activeIndex, activeIndex + 1))
|
||||
draggingIndex = activeIndex + 1
|
||||
dragOffsetPx -= dragStepPx
|
||||
scope.launch {
|
||||
pagerState.scrollToPage((currentIndex + 1).coerceAtMost(mediaItems.lastIndex))
|
||||
}
|
||||
}
|
||||
|
||||
dragOffsetPx < -dragStepPx / 2f && activeIndex > 0 -> {
|
||||
onUpdateItems(mediaItems.move(activeIndex, activeIndex - 1))
|
||||
draggingIndex = activeIndex - 1
|
||||
dragOffsetPx += dragStepPx
|
||||
scope.launch {
|
||||
pagerState.scrollToPage((currentIndex - 1).coerceAtLeast(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (item.mimeType.startsWith("video/", ignoreCase = true)) {
|
||||
@@ -3202,6 +3312,27 @@ private fun MediaBatchPreviewSheet(
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = Color.Black.copy(alpha = 0.52f),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(20.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
onUpdateItems(mediaItems.removeAt(index))
|
||||
},
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = stringResource(id = R.string.common_delete),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3285,11 +3416,53 @@ private fun List<PickedMediaPayload>.move(fromIndex: Int, toIndex: Int): List<Pi
|
||||
return mutable.toList()
|
||||
}
|
||||
|
||||
private fun List<PickedMediaPayload>.updatedAt(index: Int, item: PickedMediaPayload): List<PickedMediaPayload> {
|
||||
if (index !in indices) return this
|
||||
val mutable = toMutableList()
|
||||
mutable[index] = item
|
||||
return mutable.toList()
|
||||
}
|
||||
|
||||
private fun List<PickedMediaPayload>.removeAt(index: Int): List<PickedMediaPayload> {
|
||||
if (index !in indices) return this
|
||||
return filterIndexed { currentIndex, _ -> currentIndex != index }
|
||||
}
|
||||
|
||||
private val PickedMediaPayload.isEditableStaticImage: Boolean
|
||||
get() = mimeType.startsWith("image/", ignoreCase = true) && !mimeType.contains("gif", ignoreCase = true)
|
||||
|
||||
private fun PickedMediaPayload.rotateClockwise(): PickedMediaPayload? {
|
||||
if (!isEditableStaticImage) return this
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
|
||||
val matrix = Matrix().apply { postRotate(90f) }
|
||||
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||
return rotated.toEditedImagePayload(fileName)
|
||||
}
|
||||
|
||||
private fun PickedMediaPayload.cropCenterSquare(): PickedMediaPayload? {
|
||||
if (!isEditableStaticImage) return this
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
|
||||
val side = minOf(bitmap.width, bitmap.height)
|
||||
val left = ((bitmap.width - side) / 2).coerceAtLeast(0)
|
||||
val top = ((bitmap.height - side) / 2).coerceAtLeast(0)
|
||||
val cropped = Bitmap.createBitmap(bitmap, left, top, side, side)
|
||||
return cropped.toEditedImagePayload(fileName)
|
||||
}
|
||||
|
||||
private fun Bitmap.toEditedImagePayload(originalFileName: String): PickedMediaPayload? {
|
||||
val stream = java.io.ByteArrayOutputStream()
|
||||
val ok = compress(Bitmap.CompressFormat.JPEG, 92, stream)
|
||||
if (!ok) return null
|
||||
val bytes = stream.toByteArray()
|
||||
if (bytes.isEmpty()) return null
|
||||
val baseName = originalFileName.substringBeforeLast('.').ifBlank { "photo_${System.currentTimeMillis()}" }
|
||||
return PickedMediaPayload(
|
||||
fileName = "${baseName}.jpg",
|
||||
mimeType = "image/jpeg",
|
||||
bytes = bytes,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CameraCaptureDialog(
|
||||
lifecycleOwner: androidx.lifecycle.LifecycleOwner,
|
||||
@@ -3980,6 +4153,55 @@ private fun Uri.readMediaPayload(context: Context): PickedMediaPayload? {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberRemoteMediaAspectRatio(
|
||||
url: String,
|
||||
isVideo: Boolean,
|
||||
fallback: Float = 1f,
|
||||
): Float {
|
||||
val aspectRatio = produceState(initialValue = fallback, url, isVideo) {
|
||||
value = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
if (isVideo) {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
retriever.setDataSource(url, emptyMap())
|
||||
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toFloatOrNull()
|
||||
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toFloatOrNull()
|
||||
retriever.release()
|
||||
if (width != null && height != null && height > 0f) width / height else fallback
|
||||
} else {
|
||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
java.net.URL(url).openStream().use { input ->
|
||||
BitmapFactory.decodeStream(input, null, options)
|
||||
}
|
||||
val width = options.outWidth.toFloat()
|
||||
val height = options.outHeight.toFloat()
|
||||
if (width > 0f && height > 0f) width / height else fallback
|
||||
}
|
||||
}.getOrDefault(fallback)
|
||||
}.coerceIn(0.6f, 1.8f)
|
||||
}
|
||||
return aspectRatio.value
|
||||
}
|
||||
|
||||
private fun mediaPreviewSizeModifier(
|
||||
ratio: Float,
|
||||
maxWidth: Dp,
|
||||
maxHeight: Dp,
|
||||
): Modifier {
|
||||
val boundedRatio = ratio.coerceIn(0.6f, 1.8f)
|
||||
val maxFrameRatio = maxWidth.value / maxHeight.value
|
||||
return if (boundedRatio >= maxFrameRatio) {
|
||||
Modifier
|
||||
.width(maxWidth)
|
||||
.aspectRatio(boundedRatio)
|
||||
} else {
|
||||
Modifier
|
||||
.height(maxHeight)
|
||||
.aspectRatio(boundedRatio)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun MessageBubble(
|
||||
@@ -4211,6 +4433,11 @@ private fun MessageBubble(
|
||||
mediaBadgeLabel(fileType = "image/url", url = textUrl, context = context)
|
||||
}
|
||||
val openable = !isLegacyStickerMessage && badgeLabel == null
|
||||
val legacyImageAspectRatio = if (isLegacyStickerMessage) {
|
||||
1f
|
||||
} else {
|
||||
rememberRemoteMediaAspectRatio(url = textUrl, isVideo = false)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.then(
|
||||
@@ -4218,9 +4445,11 @@ private fun MessageBubble(
|
||||
Modifier
|
||||
.size(176.dp)
|
||||
} else {
|
||||
Modifier
|
||||
.widthIn(max = singleMediaMaxWidth)
|
||||
.heightIn(max = singleMediaMaxHeight)
|
||||
mediaPreviewSizeModifier(
|
||||
ratio = legacyImageAspectRatio,
|
||||
maxWidth = singleMediaMaxWidth,
|
||||
maxHeight = singleMediaMaxHeight,
|
||||
)
|
||||
},
|
||||
)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
@@ -4279,6 +4508,8 @@ private fun MessageBubble(
|
||||
VideoAttachmentCard(
|
||||
url = textUrl,
|
||||
fileType = message.type,
|
||||
maxWidth = singleMediaMaxWidth,
|
||||
maxHeight = singleMediaMaxHeight,
|
||||
onOpenViewer = { onAttachmentVideoClick(textUrl) },
|
||||
)
|
||||
}
|
||||
@@ -4316,6 +4547,11 @@ private fun MessageBubble(
|
||||
mediaBadgeLabel(fileType = single.fileType, url = single.fileUrl, context = context)
|
||||
}
|
||||
val openable = !isStickerImage && badgeLabel == null
|
||||
val singleImageAspectRatio = if (isStickerImage) {
|
||||
1f
|
||||
} else {
|
||||
rememberRemoteMediaAspectRatio(url = single.fileUrl, isVideo = false)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.then(
|
||||
@@ -4323,9 +4559,11 @@ private fun MessageBubble(
|
||||
Modifier
|
||||
.size(176.dp)
|
||||
} else {
|
||||
Modifier
|
||||
.widthIn(max = singleMediaMaxWidth)
|
||||
.heightIn(max = singleMediaMaxHeight)
|
||||
mediaPreviewSizeModifier(
|
||||
ratio = singleImageAspectRatio,
|
||||
maxWidth = singleMediaMaxWidth,
|
||||
maxHeight = singleMediaMaxHeight,
|
||||
)
|
||||
},
|
||||
)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
@@ -4432,6 +4670,8 @@ private fun MessageBubble(
|
||||
VideoAttachmentCard(
|
||||
url = attachment.fileUrl,
|
||||
fileType = attachment.fileType,
|
||||
maxWidth = singleMediaMaxWidth,
|
||||
maxHeight = singleMediaMaxHeight,
|
||||
onOpenViewer = { onAttachmentVideoClick(attachment.fileUrl) },
|
||||
)
|
||||
}
|
||||
@@ -4518,9 +4758,12 @@ private fun MessageBubble(
|
||||
private fun VideoAttachmentCard(
|
||||
url: String,
|
||||
fileType: String,
|
||||
maxWidth: Dp,
|
||||
maxHeight: Dp,
|
||||
onOpenViewer: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val aspectRatio = rememberRemoteMediaAspectRatio(url = url, isVideo = true, fallback = 1f)
|
||||
val previewRequest = remember(url) {
|
||||
ImageRequest.Builder(context)
|
||||
.data(url)
|
||||
@@ -4530,15 +4773,20 @@ private fun VideoAttachmentCard(
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = maxWidth)
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.45f), RoundedCornerShape(10.dp))
|
||||
.clickable(onClick = onOpenViewer)
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(186.dp)
|
||||
.then(
|
||||
mediaPreviewSizeModifier(
|
||||
ratio = aspectRatio,
|
||||
maxWidth = maxWidth - 16.dp,
|
||||
maxHeight = maxHeight,
|
||||
),
|
||||
)
|
||||
.clip(RoundedCornerShape(10.dp)),
|
||||
) {
|
||||
AsyncImage(
|
||||
@@ -4627,10 +4875,17 @@ private fun CircleVideoAttachmentPlayer(
|
||||
LaunchedEffect(playerState.isPlaying) {
|
||||
emitTopStrip(playerState.isPlaying)
|
||||
}
|
||||
val progress = if (playerState.durationMs > 0L) {
|
||||
(playerState.positionMs.toFloat() / playerState.durationMs.toFloat()).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
LaunchedEffect(playerState.positionMs, playerState.isPlaying) {
|
||||
if (!playerState.isPlaying && playerState.positionMs == 0L) {
|
||||
emitTopStrip(false)
|
||||
}
|
||||
}
|
||||
val progress = remember(playerState.positionMs, playerState.durationMs, playerState.isPlaying) {
|
||||
when {
|
||||
playerState.durationMs <= 0L -> 0f
|
||||
!playerState.isPlaying && playerState.positionMs <= 0L -> 0f
|
||||
else -> (playerState.positionMs.toFloat() / playerState.durationMs.toFloat()).coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
val progressTrackColor = Color.White.copy(alpha = 0.22f)
|
||||
val progressActiveColor = MaterialTheme.colorScheme.primary
|
||||
|
||||
@@ -364,4 +364,10 @@
|
||||
<string name="chat_media_preview_caption_hint">Добавить подпись</string>
|
||||
<string name="chat_media_preview_move_left">Переместить влево</string>
|
||||
<string name="chat_media_preview_move_right">Переместить вправо</string>
|
||||
<string name="chat_media_preview_crop_square">Обрезать в квадрат</string>
|
||||
<string name="chat_media_preview_rotate">Повернуть</string>
|
||||
<string name="chat_media_preview_count_badge">%1$d из %2$d</string>
|
||||
<string name="chat_media_preview_reorder_hint">Удерживай и перетаскивай миниатюры для сортировки. Нажми X, чтобы удалить.</string>
|
||||
<string name="chat_private_info_contact_badge">Контакт</string>
|
||||
<string name="chat_private_info_blocked_badge">Заблокирован</string>
|
||||
</resources>
|
||||
|
||||
@@ -364,4 +364,10 @@
|
||||
<string name="chat_media_preview_caption_hint">Add a caption</string>
|
||||
<string name="chat_media_preview_move_left">Move left</string>
|
||||
<string name="chat_media_preview_move_right">Move right</string>
|
||||
<string name="chat_media_preview_crop_square">Crop square</string>
|
||||
<string name="chat_media_preview_rotate">Rotate</string>
|
||||
<string name="chat_media_preview_count_badge">%1$d of %2$d</string>
|
||||
<string name="chat_media_preview_reorder_hint">Hold and drag thumbnails to reorder. Tap X to remove.</string>
|
||||
<string name="chat_private_info_contact_badge">Contact</string>
|
||||
<string name="chat_private_info_blocked_badge">Blocked</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user