feat: improve media picking and attachment previews
Some checks failed
Android CI / android (push) Failing after 5m54s
Android Release / release (push) Failing after 6m9s
CI / test (push) Failing after 2m25s

Show images and GIFs fully inside chat bubbles instead of cropping them.

Split attachment flow into a visual picker for photos and videos and a separate system file picker.

Add batch visual sending with a shared caption applied to the first item until album support exists.
This commit is contained in:
2026-04-05 20:06:19 +03:00
parent b3c121b8bc
commit 6bb3dfc712
4 changed files with 109 additions and 11 deletions

View File

@@ -16,6 +16,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -320,8 +321,16 @@ fun ChatRoute(
AppAudioFocusCoordinator.release("voice-recording")
}
}
val pickMediaLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
val pickVisualMediaLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 10),
) { uris ->
val picked = uris.mapNotNull { it.readMediaPayload(context) }
if (picked.isNotEmpty()) {
viewModel.onMediaBatchPicked(picked)
}
}
val pickFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri ->
val picked = uri?.readMediaPayload(context) ?: return@rememberLauncherForActivityResult
viewModel.onMediaPicked(
@@ -351,7 +360,14 @@ fun ChatRoute(
onToggleReaction = viewModel::onToggleReaction,
onCancelComposeAction = viewModel::onCancelComposeAction,
onLoadMore = viewModel::loadMore,
onPickMedia = { pickMediaLauncher.launch("*/*") },
onPickVisualMedia = {
pickVisualMediaLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo),
)
},
onPickFile = {
pickFileLauncher.launch(arrayOf("*/*"))
},
onCapturePhoto = {
if (hasCameraPermission) {
cameraCaptureMode = CameraCaptureMode.Photo
@@ -517,7 +533,8 @@ private data class ChatScreenActions(
val onToggleReaction: (String) -> Unit,
val onCancelComposeAction: () -> Unit,
val onLoadMore: () -> Unit,
val onPickMedia: () -> Unit,
val onPickVisualMedia: () -> Unit,
val onPickFile: () -> Unit,
val onCapturePhoto: () -> Unit,
val onCaptureVideo: () -> Unit,
val onCaptureCircleVideo: () -> Unit,
@@ -571,7 +588,8 @@ private fun ChatScreen(
val onToggleReaction = actions.onToggleReaction
val onCancelComposeAction = actions.onCancelComposeAction
val onLoadMore = actions.onLoadMore
val onPickMedia = actions.onPickMedia
val onPickVisualMedia = actions.onPickVisualMedia
val onPickFile = actions.onPickFile
val onCapturePhoto = actions.onCapturePhoto
val onCaptureVideo = actions.onCaptureVideo
val onCaptureCircleVideo = actions.onCaptureCircleVideo
@@ -2082,7 +2100,28 @@ private fun ChatScreen(
.clip(RoundedCornerShape(12.dp))
.clickable {
showAttachmentSheet = false
onPickMedia()
onPickVisualMedia()
},
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(imageVector = Icons.Filled.Image, contentDescription = null)
Text(stringResource(id = R.string.chat_attachment_photos_videos))
}
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable {
showAttachmentSheet = false
onPickFile()
},
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f),
) {
@@ -2094,7 +2133,7 @@ private fun ChatScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(imageVector = Icons.Filled.AttachFile, contentDescription = null)
Text(stringResource(id = R.string.chat_attachment_gallery_file))
Text(stringResource(id = R.string.chat_attachment_files))
}
}
Surface(
@@ -2963,7 +3002,7 @@ private fun EmojiPickerSheet(
}
}
private data class PickedMediaPayload(
data class PickedMediaPayload(
val fileName: String,
val mimeType: String,
val bytes: ByteArray,
@@ -3926,11 +3965,16 @@ private fun MessageBubble(
}
},
) {
Box(
modifier = Modifier
.matchParentSize()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)),
)
AsyncImage(
model = textUrl,
contentDescription = "Image",
modifier = Modifier.fillMaxSize(),
contentScale = if (isLegacyStickerMessage) ContentScale.Fit else ContentScale.Crop,
contentScale = ContentScale.Fit,
)
if (!badgeLabel.isNullOrBlank()) {
MediaTypeBadge(
@@ -4022,11 +4066,16 @@ private fun MessageBubble(
if (openable) base.clickable { onAttachmentImageClick(single.fileUrl) } else base
},
) {
Box(
modifier = Modifier
.matchParentSize()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)),
)
AsyncImage(
model = single.fileUrl,
contentDescription = "Image",
modifier = Modifier.fillMaxSize(),
contentScale = if (isStickerImage) ContentScale.Fit else ContentScale.Crop,
contentScale = ContentScale.Fit,
)
if (!badgeLabel.isNullOrBlank()) {
MediaTypeBadge(
@@ -4059,11 +4108,16 @@ private fun MessageBubble(
if (openable) base.clickable { onAttachmentImageClick(image.fileUrl) } else base
},
) {
Box(
modifier = Modifier
.matchParentSize()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)),
)
AsyncImage(
model = image.fileUrl,
contentDescription = "Image",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
contentScale = ContentScale.Fit,
)
if (!badgeLabel.isNullOrBlank()) {
MediaTypeBadge(

View File

@@ -662,6 +662,46 @@ class ChatViewModel @Inject constructor(
}
}
fun onMediaBatchPicked(items: List<PickedMediaPayload>) {
if (items.isEmpty()) return
viewModelScope.launch {
_uiState.update { it.copy(isUploadingMedia = true, errorMessage = null) }
val caption = uiState.value.inputText.trim().ifBlank { null }
val replyToMessageId = uiState.value.replyToMessage?.id
items.forEachIndexed { index, item ->
when (
val result = sendMediaMessageUseCase(
chatId = chatId,
fileName = item.fileName,
mimeType = item.mimeType,
bytes = item.bytes,
caption = if (index == 0) caption else null,
replyToMessageId = if (index == 0) replyToMessageId else null,
)
) {
is AppResult.Success -> Unit
is AppResult.Error -> {
_uiState.update {
it.copy(
isUploadingMedia = false,
errorMessage = result.reason.toUiMessage(),
)
}
return@launch
}
}
}
_uiState.update {
it.copy(
isUploadingMedia = false,
inputText = "",
replyToMessage = null,
editingMessage = null,
)
}
}
}
fun onSendPresetMediaUrl(url: String) {
val normalizedUrl = url.trim()
if (normalizedUrl.isBlank()) return

View File

@@ -164,6 +164,8 @@
<string name="chat_media_badge_video">Видео</string>
<string name="chat_attachment_title">Вложение</string>
<string name="chat_attachment_gallery_file">Галерея / Файл</string>
<string name="chat_attachment_photos_videos">Фото и видео</string>
<string name="chat_attachment_files">Файлы</string>
<string name="chat_attachment_take_photo">Сделать фото</string>
<string name="chat_attachment_take_video">Снять видео</string>
<string name="chat_media_badge_gif">GIF</string>

View File

@@ -164,6 +164,8 @@
<string name="chat_media_badge_video">Video</string>
<string name="chat_attachment_title">Attachment</string>
<string name="chat_attachment_gallery_file">Gallery / File</string>
<string name="chat_attachment_photos_videos">Photos &amp; videos</string>
<string name="chat_attachment_files">Files</string>
<string name="chat_attachment_take_photo">Take photo</string>
<string name="chat_attachment_take_video">Record video</string>
<string name="chat_media_badge_gif">GIF</string>