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.
|
- 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.
|
- 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.
|
- 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:1.7.6")
|
||||||
implementation("androidx.compose.ui:ui-tooling-preview:1.7.6")
|
implementation("androidx.compose.ui:ui-tooling-preview:1.7.6")
|
||||||
implementation("androidx.compose.material3:material3:1.3.1")
|
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-coroutines-android:1.9.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
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,
|
MessageEntity::class,
|
||||||
MessageAttachmentEntity::class,
|
MessageAttachmentEntity::class,
|
||||||
],
|
],
|
||||||
version = 3,
|
version = 4,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class MessengerDatabase : RoomDatabase() {
|
abstract class MessengerDatabase : RoomDatabase() {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package ru.daemonlord.messenger.data.media.api
|
package ru.daemonlord.messenger.data.media.api
|
||||||
|
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Query
|
||||||
import ru.daemonlord.messenger.data.media.dto.AttachmentCreateRequestDto
|
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.UploadUrlRequestDto
|
||||||
import ru.daemonlord.messenger.data.media.dto.UploadUrlResponseDto
|
import ru.daemonlord.messenger.data.media.dto.UploadUrlResponseDto
|
||||||
|
|
||||||
@@ -16,4 +20,11 @@ interface MediaApiService {
|
|||||||
suspend fun createAttachment(
|
suspend fun createAttachment(
|
||||||
@Body request: AttachmentCreateRequestDto,
|
@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")
|
@SerialName("file_size")
|
||||||
val fileSize: Long,
|
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 kotlinx.coroutines.flow.Flow
|
||||||
import ru.daemonlord.messenger.data.message.local.entity.MessageAttachmentEntity
|
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.entity.MessageEntity
|
||||||
|
import ru.daemonlord.messenger.data.message.local.model.MessageLocalModel
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface MessageDao {
|
interface MessageDao {
|
||||||
@@ -20,10 +21,11 @@ interface MessageDao {
|
|||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
@Transaction
|
||||||
fun observeRecentMessages(
|
fun observeRecentMessages(
|
||||||
chatId: Long,
|
chatId: Long,
|
||||||
limit: Int = 50,
|
limit: Int = 50,
|
||||||
): Flow<List<MessageEntity>>
|
): Flow<List<MessageLocalModel>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
@@ -49,6 +51,16 @@ interface MessageDao {
|
|||||||
@Query("DELETE FROM messages WHERE chat_id = :chatId")
|
@Query("DELETE FROM messages WHERE chat_id = :chatId")
|
||||||
suspend fun clearChatMessages(chatId: Long)
|
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")
|
@Query("DELETE FROM messages WHERE id = :messageId")
|
||||||
suspend fun deleteMessage(messageId: Long)
|
suspend fun deleteMessage(messageId: Long)
|
||||||
|
|
||||||
@@ -77,6 +89,7 @@ interface MessageDao {
|
|||||||
messages: List<MessageEntity>,
|
messages: List<MessageEntity>,
|
||||||
attachments: List<MessageAttachmentEntity>,
|
attachments: List<MessageAttachmentEntity>,
|
||||||
) {
|
) {
|
||||||
|
clearChatAttachments(chatId = chatId)
|
||||||
clearChatMessages(chatId = chatId)
|
clearChatMessages(chatId = chatId)
|
||||||
upsertMessages(messages)
|
upsertMessages(messages)
|
||||||
if (attachments.isNotEmpty()) {
|
if (attachments.isNotEmpty()) {
|
||||||
|
|||||||
@@ -23,4 +23,6 @@ data class MessageAttachmentEntity(
|
|||||||
val fileType: String,
|
val fileType: String,
|
||||||
@ColumnInfo(name = "file_size")
|
@ColumnInfo(name = "file_size")
|
||||||
val fileSize: Long,
|
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.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import ru.daemonlord.messenger.data.message.dto.MessageReadDto
|
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.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.MessageReaction
|
||||||
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
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(
|
return MessageItem(
|
||||||
id = id,
|
id = message.id,
|
||||||
chatId = chatId,
|
chatId = message.chatId,
|
||||||
senderId = senderId,
|
senderId = message.senderId,
|
||||||
senderDisplayName = senderDisplayName,
|
senderDisplayName = message.senderDisplayName,
|
||||||
type = type,
|
type = message.type,
|
||||||
text = text,
|
text = message.text,
|
||||||
createdAt = createdAt,
|
createdAt = message.createdAt,
|
||||||
updatedAt = updatedAt,
|
updatedAt = message.updatedAt,
|
||||||
isOutgoing = currentUserId != null && currentUserId == senderId,
|
isOutgoing = currentUserId != null && currentUserId == message.senderId,
|
||||||
status = status,
|
status = message.status,
|
||||||
replyToMessageId = replyToMessageId,
|
replyToMessageId = message.replyToMessageId,
|
||||||
forwardedFromMessageId = forwardedFromMessageId,
|
forwardedFromMessageId = message.forwardedFromMessageId,
|
||||||
attachmentWaveform = attachmentWaveformJson.toWaveformOrNull(),
|
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(
|
return MessageReaction(
|
||||||
emoji = emoji,
|
emoji = emoji,
|
||||||
count = count,
|
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>? {
|
private fun String?.toWaveformOrNull(): List<Int>? {
|
||||||
if (this.isNullOrBlank()) return null
|
if (this.isNullOrBlank()) return null
|
||||||
return runCatching { mapperJson.decodeFromString<List<Int>>(this) }.getOrNull()
|
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.local.entity.MessageEntity
|
||||||
import ru.daemonlord.messenger.data.message.mapper.toDomain
|
import ru.daemonlord.messenger.data.message.mapper.toDomain
|
||||||
import ru.daemonlord.messenger.data.message.mapper.toEntity
|
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.di.IoDispatcher
|
||||||
import ru.daemonlord.messenger.domain.common.AppError
|
import ru.daemonlord.messenger.domain.common.AppError
|
||||||
import ru.daemonlord.messenger.domain.common.AppResult
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
@@ -44,6 +45,7 @@ class NetworkMessageRepository @Inject constructor(
|
|||||||
private val messageDao: MessageDao,
|
private val messageDao: MessageDao,
|
||||||
private val chatDao: ChatDao,
|
private val chatDao: ChatDao,
|
||||||
private val mediaRepository: MediaRepository,
|
private val mediaRepository: MediaRepository,
|
||||||
|
private val mediaApiService: MediaApiService,
|
||||||
tokenRepository: TokenRepository,
|
tokenRepository: TokenRepository,
|
||||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||||
) : MessageRepository {
|
) : MessageRepository {
|
||||||
@@ -70,10 +72,12 @@ class NetworkMessageRepository @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
val remoteMessages = messageApiService.getMessages(chatId = chatId)
|
val remoteMessages = messageApiService.getMessages(chatId = chatId)
|
||||||
val messageEntities = remoteMessages.map { it.toEntity() }
|
val messageEntities = remoteMessages.map { it.toEntity() }
|
||||||
|
val attachments = mediaApiService.getChatAttachments(chatId = chatId, limit = 400)
|
||||||
|
.map { it.toEntity() }
|
||||||
messageDao.clearAndReplaceMessages(
|
messageDao.clearAndReplaceMessages(
|
||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
messages = messageEntities,
|
messages = messageEntities,
|
||||||
attachments = emptyList(),
|
attachments = attachments,
|
||||||
)
|
)
|
||||||
AppResult.Success(Unit)
|
AppResult.Success(Unit)
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
@@ -89,6 +93,13 @@ class NetworkMessageRepository @Inject constructor(
|
|||||||
)
|
)
|
||||||
if (page.isNotEmpty()) {
|
if (page.isNotEmpty()) {
|
||||||
messageDao.upsertMessages(page.map { it.toEntity() })
|
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)
|
AppResult.Success(Unit)
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
@@ -231,15 +242,7 @@ class NetworkMessageRepository @Inject constructor(
|
|||||||
)) {
|
)) {
|
||||||
is AppResult.Success -> {
|
is AppResult.Success -> {
|
||||||
messageDao.deleteMessage(tempId)
|
messageDao.deleteMessage(tempId)
|
||||||
messageDao.upsertMessages(listOf(created.toEntity()))
|
syncRecentMessages(chatId = chatId)
|
||||||
chatDao.updateLastMessage(
|
|
||||||
chatId = chatId,
|
|
||||||
lastMessageText = created.text,
|
|
||||||
lastMessageType = created.type,
|
|
||||||
lastMessageCreatedAt = created.createdAt,
|
|
||||||
updatedSortAt = created.createdAt,
|
|
||||||
)
|
|
||||||
AppResult.Success(Unit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is AppResult.Error -> {
|
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 replyToMessageId: Long?,
|
||||||
val forwardedFromMessageId: Long?,
|
val forwardedFromMessageId: Long?,
|
||||||
val attachmentWaveform: List<Int>?,
|
val attachmentWaveform: List<Int>?,
|
||||||
|
val attachments: List<MessageAttachment>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
package ru.daemonlord.messenger.ui.chat
|
package ru.daemonlord.messenger.ui.chat
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.MediaPlayer
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coil.compose.AsyncImage
|
||||||
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -88,6 +101,7 @@ fun ChatScreen(
|
|||||||
onLoadMore: () -> Unit,
|
onLoadMore: () -> Unit,
|
||||||
onPickMedia: () -> Unit,
|
onPickMedia: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
var viewerImageUrl by remember { mutableStateOf<String?>(null) }
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
@@ -129,6 +143,7 @@ fun ChatScreen(
|
|||||||
message = message,
|
message = message,
|
||||||
isSelected = state.selectedMessage?.id == message.id,
|
isSelected = state.selectedMessage?.id == message.id,
|
||||||
reactions = state.reactionByMessageId[message.id].orEmpty(),
|
reactions = state.reactionByMessageId[message.id].orEmpty(),
|
||||||
|
onAttachmentImageClick = { imageUrl -> viewerImageUrl = imageUrl },
|
||||||
onLongPress = { onSelectMessage(message) },
|
onLongPress = { onSelectMessage(message) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -244,6 +259,26 @@ fun ChatScreen(
|
|||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
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,
|
message: MessageItem,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
reactions: List<ru.daemonlord.messenger.domain.message.model.MessageReaction>,
|
reactions: List<ru.daemonlord.messenger.domain.message.model.MessageReaction>,
|
||||||
|
onAttachmentImageClick: (String) -> Unit,
|
||||||
onLongPress: () -> Unit,
|
onLongPress: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val isOutgoing = message.isOutgoing
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.End,
|
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()
|
val chat20 = dao.observeRecentMessages(chatId = 20).first()
|
||||||
|
|
||||||
assertEquals(1, chat10.size)
|
assertEquals(1, chat10.size)
|
||||||
assertEquals(3L, chat10.first().id)
|
assertEquals(3L, chat10.first().message.id)
|
||||||
assertEquals(1, chat20.size)
|
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 {
|
private fun message(id: Long, chatId: Long, text: String): MessageEntity {
|
||||||
|
|||||||
@@ -60,10 +60,10 @@
|
|||||||
## 8. Медиа и вложения
|
## 8. Медиа и вложения
|
||||||
- [x] Upload image/video/file/audio
|
- [x] Upload image/video/file/audio
|
||||||
- [ ] Галерея в сообщении (multi media)
|
- [ ] Галерея в сообщении (multi media)
|
||||||
- [ ] Media viewer (zoom/swipe/download)
|
- [x] Media viewer (zoom/swipe/download)
|
||||||
- [ ] Единое контекстное меню для медиа
|
- [ ] Единое контекстное меню для медиа
|
||||||
- [ ] Voice playback waveform + speed
|
- [ ] Voice playback waveform + speed
|
||||||
- [ ] Audio player UI (не как voice)
|
- [x] Audio player UI (не как voice)
|
||||||
- [ ] Circle video playback (view-only при необходимости)
|
- [ ] Circle video playback (view-only при необходимости)
|
||||||
|
|
||||||
## 9. Запись голосовых
|
## 9. Запись голосовых
|
||||||
|
|||||||
Reference in New Issue
Block a user