android: add chat media attachment rendering and viewer
Some checks failed
CI / test (push) Failing after 2m6s
Some checks failed
CI / test (push) Failing after 2m6s
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<ChatAttachmentReadDto>
|
||||
}
|
||||
|
||||
@@ -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<Int>? = null,
|
||||
)
|
||||
|
||||
@@ -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<List<MessageEntity>>
|
||||
): Flow<List<MessageLocalModel>>
|
||||
|
||||
@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<MessageEntity>,
|
||||
attachments: List<MessageAttachmentEntity>,
|
||||
) {
|
||||
clearChatAttachments(chatId = chatId)
|
||||
clearChatMessages(chatId = chatId)
|
||||
upsertMessages(messages)
|
||||
if (attachments.isNotEmpty()) {
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
@@ -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<MessageAttachmentEntity>,
|
||||
)
|
||||
@@ -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<Int>? {
|
||||
if (this.isNullOrBlank()) return null
|
||||
return runCatching { mapperJson.decodeFromString<List<Int>>(this) }.getOrNull()
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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<Int>?,
|
||||
)
|
||||
@@ -14,4 +14,5 @@ data class MessageItem(
|
||||
val replyToMessageId: Long?,
|
||||
val forwardedFromMessageId: Long?,
|
||||
val attachmentWaveform: List<Int>?,
|
||||
val attachments: List<MessageAttachment>,
|
||||
)
|
||||
|
||||
@@ -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<String?>(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<ru.daemonlord.messenger.domain.message.model.MessageReaction>,
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. Запись голосовых
|
||||
|
||||
Reference in New Issue
Block a user