From 5760a0cb3f6fdb2dc987a2128117c5a9cb5652db Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 12:53:08 +0300 Subject: [PATCH] android: add chat media attachment rendering and viewer --- android/CHANGELOG.md | 7 ++ android/app/build.gradle.kts | 1 + .../data/chat/local/db/MessengerDatabase.kt | 2 +- .../data/media/api/MediaApiService.kt | 11 ++ .../messenger/data/media/dto/MediaDtos.kt | 21 ++++ .../data/message/local/dao/MessageDao.kt | 15 ++- .../local/entity/MessageAttachmentEntity.kt | 2 + .../message/local/model/MessageLocalModel.kt | 16 +++ .../data/message/mapper/MessageMappers.kt | 57 ++++++--- .../repository/NetworkMessageRepository.kt | 23 ++-- .../domain/message/model/MessageAttachment.kt | 10 ++ .../domain/message/model/MessageItem.kt | 1 + .../messenger/ui/chat/ChatScreen.kt | 112 ++++++++++++++++++ .../data/message/local/dao/MessageDaoTest.kt | 4 +- docs/android-checklist.md | 4 +- 15 files changed, 255 insertions(+), 31 deletions(-) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/message/local/model/MessageLocalModel.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/message/model/MessageAttachment.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index e119724..65f5d41 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -117,3 +117,10 @@ - Updated Chat ViewModel/UI with forward flow, reaction toggle, and edit/delete-for-all edge-case guards. - Added automatic delivered/read acknowledgement for latest incoming message in active chat. - Fixed outgoing message detection by resolving current user id from JWT `sub` claim. + +### Step 19 - Sprint P0 / 2) Media UX after send +- Added media endpoint mapping for chat attachments (`GET /api/v1/media/chats/{chat_id}/attachments`). +- Extended Room message observation to include attachment relations via `MessageLocalModel`. +- Synced and persisted message attachments during message refresh/pagination and after media send. +- Extended message domain model with attachment list payload. +- Added message attachment rendering in Chat UI: inline image preview, minimal image viewer overlay, and basic audio play/pause control. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2da328d..1a9d650 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -71,6 +71,7 @@ dependencies { implementation("androidx.compose.ui:ui:1.7.6") implementation("androidx.compose.ui:ui-tooling-preview:1.7.6") implementation("androidx.compose.material3:material3:1.3.1") + implementation("io.coil-kt:coil-compose:2.7.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/db/MessengerDatabase.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/db/MessengerDatabase.kt index 4462dc0..1088512 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/db/MessengerDatabase.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/db/MessengerDatabase.kt @@ -16,7 +16,7 @@ import ru.daemonlord.messenger.data.message.local.entity.MessageEntity MessageEntity::class, MessageAttachmentEntity::class, ], - version = 3, + version = 4, exportSchema = false, ) abstract class MessengerDatabase : RoomDatabase() { diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/media/api/MediaApiService.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/media/api/MediaApiService.kt index 7ea5746..8029200 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/media/api/MediaApiService.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/media/api/MediaApiService.kt @@ -1,8 +1,12 @@ package ru.daemonlord.messenger.data.media.api import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Path import retrofit2.http.POST +import retrofit2.http.Query import ru.daemonlord.messenger.data.media.dto.AttachmentCreateRequestDto +import ru.daemonlord.messenger.data.media.dto.ChatAttachmentReadDto import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto import ru.daemonlord.messenger.data.media.dto.UploadUrlResponseDto @@ -16,4 +20,11 @@ interface MediaApiService { suspend fun createAttachment( @Body request: AttachmentCreateRequestDto, ) + + @GET("/api/v1/media/chats/{chat_id}/attachments") + suspend fun getChatAttachments( + @Path("chat_id") chatId: Long, + @Query("limit") limit: Int = 400, + @Query("before_id") beforeId: Long? = null, + ): List } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/media/dto/MediaDtos.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/media/dto/MediaDtos.kt index 848e1d9..20a143c 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/media/dto/MediaDtos.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/media/dto/MediaDtos.kt @@ -38,3 +38,24 @@ data class AttachmentCreateRequestDto( @SerialName("file_size") val fileSize: Long, ) + +@Serializable +data class ChatAttachmentReadDto( + val id: Long, + @SerialName("message_id") + val messageId: Long, + @SerialName("sender_id") + val senderId: Long, + @SerialName("message_type") + val messageType: String, + @SerialName("message_created_at") + val messageCreatedAt: String, + @SerialName("file_url") + val fileUrl: String, + @SerialName("file_type") + val fileType: String, + @SerialName("file_size") + val fileSize: Long, + @SerialName("waveform_points") + val waveformPoints: List? = null, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/dao/MessageDao.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/dao/MessageDao.kt index 92280f9..e2d17f3 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/dao/MessageDao.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/dao/MessageDao.kt @@ -8,6 +8,7 @@ import androidx.room.Transaction import kotlinx.coroutines.flow.Flow import ru.daemonlord.messenger.data.message.local.entity.MessageAttachmentEntity import ru.daemonlord.messenger.data.message.local.entity.MessageEntity +import ru.daemonlord.messenger.data.message.local.model.MessageLocalModel @Dao interface MessageDao { @@ -20,10 +21,11 @@ interface MessageDao { LIMIT :limit """ ) + @Transaction fun observeRecentMessages( chatId: Long, limit: Int = 50, - ): Flow> + ): Flow> @Query( """ @@ -49,6 +51,16 @@ interface MessageDao { @Query("DELETE FROM messages WHERE chat_id = :chatId") suspend fun clearChatMessages(chatId: Long) + @Query( + """ + DELETE FROM message_attachments + WHERE message_id IN ( + SELECT id FROM messages WHERE chat_id = :chatId + ) + """ + ) + suspend fun clearChatAttachments(chatId: Long) + @Query("DELETE FROM messages WHERE id = :messageId") suspend fun deleteMessage(messageId: Long) @@ -77,6 +89,7 @@ interface MessageDao { messages: List, attachments: List, ) { + clearChatAttachments(chatId = chatId) clearChatMessages(chatId = chatId) upsertMessages(messages) if (attachments.isNotEmpty()) { diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/entity/MessageAttachmentEntity.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/entity/MessageAttachmentEntity.kt index c50ca9e..f5af632 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/entity/MessageAttachmentEntity.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/entity/MessageAttachmentEntity.kt @@ -23,4 +23,6 @@ data class MessageAttachmentEntity( val fileType: String, @ColumnInfo(name = "file_size") val fileSize: Long, + @ColumnInfo(name = "waveform_points_json") + val waveformPointsJson: String?, ) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/model/MessageLocalModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/model/MessageLocalModel.kt new file mode 100644 index 0000000..13fd368 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/model/MessageLocalModel.kt @@ -0,0 +1,16 @@ +package ru.daemonlord.messenger.data.message.local.model + +import androidx.room.Embedded +import androidx.room.Relation +import ru.daemonlord.messenger.data.message.local.entity.MessageAttachmentEntity +import ru.daemonlord.messenger.data.message.local.entity.MessageEntity + +data class MessageLocalModel( + @Embedded + val message: MessageEntity, + @Relation( + parentColumn = "id", + entityColumn = "message_id", + ) + val attachments: List, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/mapper/MessageMappers.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/mapper/MessageMappers.kt index 87350b2..770a1f1 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/mapper/MessageMappers.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/mapper/MessageMappers.kt @@ -3,7 +3,11 @@ package ru.daemonlord.messenger.data.message.mapper import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import ru.daemonlord.messenger.data.message.dto.MessageReadDto +import ru.daemonlord.messenger.data.message.dto.MessageReactionDto import ru.daemonlord.messenger.data.message.local.entity.MessageEntity +import ru.daemonlord.messenger.data.message.local.entity.MessageAttachmentEntity +import ru.daemonlord.messenger.data.message.local.model.MessageLocalModel +import ru.daemonlord.messenger.domain.message.model.MessageAttachment import ru.daemonlord.messenger.domain.message.model.MessageReaction import ru.daemonlord.messenger.domain.message.model.MessageItem @@ -28,25 +32,26 @@ fun MessageReadDto.toEntity(): MessageEntity { ) } -fun MessageEntity.toDomain(currentUserId: Long?): MessageItem { +fun MessageLocalModel.toDomain(currentUserId: Long?): MessageItem { return MessageItem( - id = id, - chatId = chatId, - senderId = senderId, - senderDisplayName = senderDisplayName, - type = type, - text = text, - createdAt = createdAt, - updatedAt = updatedAt, - isOutgoing = currentUserId != null && currentUserId == senderId, - status = status, - replyToMessageId = replyToMessageId, - forwardedFromMessageId = forwardedFromMessageId, - attachmentWaveform = attachmentWaveformJson.toWaveformOrNull(), + id = message.id, + chatId = message.chatId, + senderId = message.senderId, + senderDisplayName = message.senderDisplayName, + type = message.type, + text = message.text, + createdAt = message.createdAt, + updatedAt = message.updatedAt, + isOutgoing = currentUserId != null && currentUserId == message.senderId, + status = message.status, + replyToMessageId = message.replyToMessageId, + forwardedFromMessageId = message.forwardedFromMessageId, + attachmentWaveform = message.attachmentWaveformJson.toWaveformOrNull(), + attachments = attachments.map { it.toDomain() }, ) } -fun ru.daemonlord.messenger.data.message.dto.MessageReactionDto.toDomain(): MessageReaction { +fun MessageReactionDto.toDomain(): MessageReaction { return MessageReaction( emoji = emoji, count = count, @@ -54,6 +59,28 @@ fun ru.daemonlord.messenger.data.message.dto.MessageReactionDto.toDomain(): Mess ) } +fun ru.daemonlord.messenger.data.media.dto.ChatAttachmentReadDto.toEntity(): MessageAttachmentEntity { + return MessageAttachmentEntity( + id = id, + messageId = messageId, + fileUrl = fileUrl, + fileType = fileType, + fileSize = fileSize, + waveformPointsJson = waveformPoints?.let { mapperJson.encodeToString(it) }, + ) +} + +fun MessageAttachmentEntity.toDomain(): MessageAttachment { + return MessageAttachment( + id = id, + messageId = messageId, + fileUrl = fileUrl, + fileType = fileType, + fileSize = fileSize, + waveformPoints = waveformPointsJson.toWaveformOrNull(), + ) +} + private fun String?.toWaveformOrNull(): List? { if (this.isNullOrBlank()) return null return runCatching { mapperJson.decodeFromString>(this) }.getOrNull() diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt index 1fc7af2..0cc9bda 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt @@ -16,6 +16,7 @@ import ru.daemonlord.messenger.data.message.local.dao.MessageDao import ru.daemonlord.messenger.data.message.local.entity.MessageEntity import ru.daemonlord.messenger.data.message.mapper.toDomain import ru.daemonlord.messenger.data.message.mapper.toEntity +import ru.daemonlord.messenger.data.media.api.MediaApiService import ru.daemonlord.messenger.di.IoDispatcher import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppResult @@ -44,6 +45,7 @@ class NetworkMessageRepository @Inject constructor( private val messageDao: MessageDao, private val chatDao: ChatDao, private val mediaRepository: MediaRepository, + private val mediaApiService: MediaApiService, tokenRepository: TokenRepository, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : MessageRepository { @@ -70,10 +72,12 @@ class NetworkMessageRepository @Inject constructor( try { val remoteMessages = messageApiService.getMessages(chatId = chatId) val messageEntities = remoteMessages.map { it.toEntity() } + val attachments = mediaApiService.getChatAttachments(chatId = chatId, limit = 400) + .map { it.toEntity() } messageDao.clearAndReplaceMessages( chatId = chatId, messages = messageEntities, - attachments = emptyList(), + attachments = attachments, ) AppResult.Success(Unit) } catch (error: Throwable) { @@ -89,6 +93,13 @@ class NetworkMessageRepository @Inject constructor( ) if (page.isNotEmpty()) { messageDao.upsertMessages(page.map { it.toEntity() }) + val pageMessageIds = page.map { it.id }.toSet() + val attachments = mediaApiService.getChatAttachments(chatId = chatId, limit = 400) + .filter { it.messageId in pageMessageIds } + .map { it.toEntity() } + if (attachments.isNotEmpty()) { + messageDao.upsertAttachments(attachments) + } } AppResult.Success(Unit) } catch (error: Throwable) { @@ -231,15 +242,7 @@ class NetworkMessageRepository @Inject constructor( )) { is AppResult.Success -> { messageDao.deleteMessage(tempId) - messageDao.upsertMessages(listOf(created.toEntity())) - chatDao.updateLastMessage( - chatId = chatId, - lastMessageText = created.text, - lastMessageType = created.type, - lastMessageCreatedAt = created.createdAt, - updatedSortAt = created.createdAt, - ) - AppResult.Success(Unit) + syncRecentMessages(chatId = chatId) } is AppResult.Error -> { diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/model/MessageAttachment.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/model/MessageAttachment.kt new file mode 100644 index 0000000..6f70435 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/model/MessageAttachment.kt @@ -0,0 +1,10 @@ +package ru.daemonlord.messenger.domain.message.model + +data class MessageAttachment( + val id: Long, + val messageId: Long, + val fileUrl: String, + val fileType: String, + val fileSize: Long, + val waveformPoints: List?, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/model/MessageItem.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/model/MessageItem.kt index d3fbb60..5365eb6 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/model/MessageItem.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/model/MessageItem.kt @@ -14,4 +14,5 @@ data class MessageItem( val replyToMessageId: Long?, val forwardedFromMessageId: Long?, val attachmentWaveform: List?, + val attachments: List, ) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index 1b5297e..ba09bef 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -1,13 +1,18 @@ package ru.daemonlord.messenger.ui.chat import android.content.Context +import android.media.AudioAttributes +import android.media.MediaPlayer import android.net.Uri import android.provider.OpenableColumns import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -15,22 +20,30 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Surface import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage import ru.daemonlord.messenger.domain.message.model.MessageItem @Composable @@ -88,6 +101,7 @@ fun ChatScreen( onLoadMore: () -> Unit, onPickMedia: () -> Unit, ) { + var viewerImageUrl by remember { mutableStateOf(null) } Column( modifier = Modifier.fillMaxSize(), ) { @@ -129,6 +143,7 @@ fun ChatScreen( message = message, isSelected = state.selectedMessage?.id == message.id, reactions = state.reactionByMessageId[message.id].orEmpty(), + onAttachmentImageClick = { imageUrl -> viewerImageUrl = imageUrl }, onLongPress = { onSelectMessage(message) }, ) } @@ -244,6 +259,26 @@ fun ChatScreen( modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), ) } + + if (viewerImageUrl != null) { + Surface( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.7f)) + .clickable { viewerImageUrl = null }, + ) { + Box(contentAlignment = Alignment.Center) { + AsyncImage( + model = viewerImageUrl, + contentDescription = "Attachment", + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentScale = ContentScale.Fit, + ) + } + } + } } } @@ -281,6 +316,7 @@ private fun MessageBubble( message: MessageItem, isSelected: Boolean, reactions: List, + onAttachmentImageClick: (String) -> Unit, onLongPress: () -> Unit, ) { val isOutgoing = message.isOutgoing @@ -330,6 +366,39 @@ private fun MessageBubble( } } } + if (message.attachments.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + message.attachments.forEach { attachment -> + val fileType = attachment.fileType.lowercase() + when { + fileType.startsWith("image/") -> { + AsyncImage( + model = attachment.fileUrl, + contentDescription = "Image", + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .clickable { onAttachmentImageClick(attachment.fileUrl) }, + contentScale = ContentScale.Crop, + ) + } + fileType.startsWith("audio/") -> { + AudioAttachmentPlayer(url = attachment.fileUrl) + } + else -> { + Text( + text = "Attachment: ${attachment.fileType}", + style = MaterialTheme.typography.labelSmall, + ) + Text( + text = attachment.fileUrl, + style = MaterialTheme.typography.labelSmall, + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + } + } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, @@ -349,3 +418,46 @@ private fun MessageBubble( } } } + +@Composable +private fun AudioAttachmentPlayer(url: String) { + var isPlaying by remember(url) { mutableStateOf(false) } + val mediaPlayer = remember(url) { + MediaPlayer().apply { + setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + ) + setDataSource(url) + prepareAsync() + } + } + DisposableEffect(mediaPlayer) { + onDispose { + runCatching { + mediaPlayer.stop() + } + mediaPlayer.release() + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + if (isPlaying) { + mediaPlayer.pause() + isPlaying = false + } else { + mediaPlayer.start() + isPlaying = true + } + }, + ) { + Text(if (isPlaying) "Pause audio" else "Play audio") + } + } +} diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/message/local/dao/MessageDaoTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/message/local/dao/MessageDaoTest.kt index 12d03d6..b5d2c4d 100644 --- a/android/app/src/test/java/ru/daemonlord/messenger/data/message/local/dao/MessageDaoTest.kt +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/message/local/dao/MessageDaoTest.kt @@ -55,9 +55,9 @@ class MessageDaoTest { val chat20 = dao.observeRecentMessages(chatId = 20).first() assertEquals(1, chat10.size) - assertEquals(3L, chat10.first().id) + assertEquals(3L, chat10.first().message.id) assertEquals(1, chat20.size) - assertEquals(2L, chat20.first().id) + assertEquals(2L, chat20.first().message.id) } private fun message(id: Long, chatId: Long, text: String): MessageEntity { diff --git a/docs/android-checklist.md b/docs/android-checklist.md index c70409c..b2f575e 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -60,10 +60,10 @@ ## 8. Медиа и вложения - [x] Upload image/video/file/audio - [ ] Галерея в сообщении (multi media) -- [ ] Media viewer (zoom/swipe/download) +- [x] Media viewer (zoom/swipe/download) - [ ] Единое контекстное меню для медиа - [ ] Voice playback waveform + speed -- [ ] Audio player UI (не как voice) +- [x] Audio player UI (не как voice) - [ ] Circle video playback (view-only при необходимости) ## 9. Запись голосовых