From 8d13eb104eded21cff3e6eccfaf5b965aeabf23c Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 02:22:48 +0300 Subject: [PATCH] android: add media upload repository and chat attachment send flow --- android/CHANGELOG.md | 7 ++ .../data/media/api/MediaApiService.kt | 19 ++++ .../messenger/data/media/dto/MediaDtos.kt | 40 +++++++++ .../repository/NetworkMediaRepository.kt | 86 +++++++++++++++++++ .../messenger/data/message/dto/MessageDtos.kt | 2 +- .../repository/NetworkMessageRepository.kt | 84 ++++++++++++++++++ .../daemonlord/messenger/di/NetworkModule.kt | 7 ++ .../messenger/di/RepositoryModule.kt | 8 ++ .../media/repository/MediaRepository.kt | 12 +++ .../usecase/UploadAndAttachMediaUseCase.kt | 23 +++++ .../message/repository/MessageRepository.kt | 8 ++ .../usecase/SendMediaMessageUseCase.kt | 27 ++++++ .../messenger/ui/chat/ChatScreen.kt | 56 +++++++++++- .../messenger/ui/chat/ChatViewModel.kt | 40 +++++++++ .../messenger/ui/chat/MessageUiState.kt | 1 + 15 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/media/api/MediaApiService.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/media/dto/MediaDtos.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/media/repository/NetworkMediaRepository.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/media/repository/MediaRepository.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/media/usecase/UploadAndAttachMediaUseCase.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/SendMediaMessageUseCase.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 1835df3..f77b48c 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -97,3 +97,10 @@ - Added DAO test for message scoped replace behavior in Room. - Expanded realtime parser tests with rich `receive_message` mapping coverage. - Updated `docs/android-checklist.md` for completed message-core items. + +### Step 16 - Sprint B / 1-2) Media data layer + chat integration +- Added media API/DTO layer for upload URL and attachment creation. +- Added `MediaRepository` + `UploadAndAttachMediaUseCase` and network implementation with presigned PUT upload. +- Extended `MessageRepository` with media send flow (`sendMediaMessage`) and optimistic local update behavior. +- Wired media API/repository through Hilt modules. +- Integrated file picking and media sending into Android `ChatScreen`/`ChatViewModel` with upload state handling. 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 new file mode 100644 index 0000000..7ea5746 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/media/api/MediaApiService.kt @@ -0,0 +1,19 @@ +package ru.daemonlord.messenger.data.media.api + +import retrofit2.http.Body +import retrofit2.http.POST +import ru.daemonlord.messenger.data.media.dto.AttachmentCreateRequestDto +import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto +import ru.daemonlord.messenger.data.media.dto.UploadUrlResponseDto + +interface MediaApiService { + @POST("/api/v1/media/upload-url") + suspend fun requestUploadUrl( + @Body request: UploadUrlRequestDto, + ): UploadUrlResponseDto + + @POST("/api/v1/media/attachments") + suspend fun createAttachment( + @Body request: AttachmentCreateRequestDto, + ) +} 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 new file mode 100644 index 0000000..848e1d9 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/media/dto/MediaDtos.kt @@ -0,0 +1,40 @@ +package ru.daemonlord.messenger.data.media.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UploadUrlRequestDto( + @SerialName("file_name") + val fileName: String, + @SerialName("file_type") + val fileType: String, + @SerialName("file_size") + val fileSize: Long, +) + +@Serializable +data class UploadUrlResponseDto( + @SerialName("upload_url") + val uploadUrl: String, + @SerialName("file_url") + val fileUrl: String, + @SerialName("object_key") + val objectKey: String, + @SerialName("expires_in") + val expiresIn: Int, + @SerialName("required_headers") + val requiredHeaders: Map = emptyMap(), +) + +@Serializable +data class AttachmentCreateRequestDto( + @SerialName("message_id") + val messageId: Long, + @SerialName("file_url") + val fileUrl: String, + @SerialName("file_type") + val fileType: String, + @SerialName("file_size") + val fileSize: Long, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/media/repository/NetworkMediaRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/media/repository/NetworkMediaRepository.kt new file mode 100644 index 0000000..2ebee52 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/media/repository/NetworkMediaRepository.kt @@ -0,0 +1,86 @@ +package ru.daemonlord.messenger.data.media.repository + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import retrofit2.HttpException +import ru.daemonlord.messenger.data.media.api.MediaApiService +import ru.daemonlord.messenger.data.media.dto.AttachmentCreateRequestDto +import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto +import ru.daemonlord.messenger.di.IoDispatcher +import ru.daemonlord.messenger.di.RefreshClient +import ru.daemonlord.messenger.domain.common.AppError +import ru.daemonlord.messenger.domain.common.AppResult +import ru.daemonlord.messenger.domain.media.repository.MediaRepository +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NetworkMediaRepository @Inject constructor( + private val mediaApiService: MediaApiService, + @RefreshClient private val uploadClient: OkHttpClient, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) : MediaRepository { + + override suspend fun uploadAndAttach( + messageId: Long, + fileName: String, + mimeType: String, + bytes: ByteArray, + ): AppResult = withContext(ioDispatcher) { + try { + val uploadInfo = mediaApiService.requestUploadUrl( + request = UploadUrlRequestDto( + fileName = fileName, + fileType = mimeType, + fileSize = bytes.size.toLong(), + ) + ) + + val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull()) + val uploadRequestBuilder = Request.Builder() + .url(uploadInfo.uploadUrl) + .put(body) + + uploadInfo.requiredHeaders.forEach { (key, value) -> + uploadRequestBuilder.header(key, value) + } + + uploadClient.newCall(uploadRequestBuilder.build()).execute().use { response -> + if (!response.isSuccessful) { + return@withContext AppResult.Error( + AppError.Server(message = "Upload failed: HTTP ${response.code}") + ) + } + } + + mediaApiService.createAttachment( + request = AttachmentCreateRequestDto( + messageId = messageId, + fileUrl = uploadInfo.fileUrl, + fileType = mimeType, + fileSize = bytes.size.toLong(), + ) + ) + AppResult.Success(Unit) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + private fun Throwable.toAppError(): AppError { + return when (this) { + is IOException -> AppError.Network + is HttpException -> if (code() == 401 || code() == 403) { + AppError.Unauthorized + } else { + AppError.Server(message = message()) + } + else -> AppError.Unknown(cause = this) + } + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/dto/MessageDtos.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/dto/MessageDtos.kt index be19189..0cd4959 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/dto/MessageDtos.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/dto/MessageDtos.kt @@ -27,7 +27,7 @@ data class MessageCreateRequestDto( @SerialName("chat_id") val chatId: Long, val type: String, - val text: String, + val text: String? = null, @SerialName("client_message_id") val clientMessageId: String, @SerialName("reply_to_message_id") 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 d9edf03..7014c1e 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 @@ -12,6 +12,7 @@ import ru.daemonlord.messenger.data.message.mapper.toEntity import ru.daemonlord.messenger.di.IoDispatcher import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppResult +import ru.daemonlord.messenger.domain.media.repository.MediaRepository import ru.daemonlord.messenger.domain.message.model.MessageItem import ru.daemonlord.messenger.domain.message.repository.MessageRepository import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto @@ -28,6 +29,7 @@ class NetworkMessageRepository @Inject constructor( private val messageApiService: MessageApiService, private val messageDao: MessageDao, private val chatDao: ChatDao, + private val mediaRepository: MediaRepository, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : MessageRepository { @@ -149,6 +151,88 @@ class NetworkMessageRepository @Inject constructor( } } + override suspend fun sendMediaMessage( + chatId: Long, + fileName: String, + mimeType: String, + bytes: ByteArray, + caption: String?, + replyToMessageId: Long?, + ): AppResult = withContext(ioDispatcher) { + val messageType = mapMimeToMessageType(mimeType) + val tempId = -System.currentTimeMillis() + val tempMessage = MessageEntity( + id = tempId, + chatId = chatId, + senderId = currentUserId ?: 0L, + senderDisplayName = null, + senderUsername = null, + senderAvatarUrl = null, + replyToMessageId = replyToMessageId, + type = messageType, + text = caption, + status = "pending", + createdAt = java.time.Instant.now().toString(), + updatedAt = null, + ) + messageDao.upsertMessages(listOf(tempMessage)) + chatDao.updateLastMessage( + chatId = chatId, + lastMessageText = caption, + lastMessageType = messageType, + lastMessageCreatedAt = tempMessage.createdAt, + updatedSortAt = tempMessage.createdAt, + ) + try { + val created = messageApiService.sendMessage( + request = MessageCreateRequestDto( + chatId = chatId, + type = messageType, + text = caption, + clientMessageId = UUID.randomUUID().toString(), + replyToMessageId = replyToMessageId, + ) + ) + + when (val mediaResult = mediaRepository.uploadAndAttach( + messageId = created.id, + fileName = fileName, + mimeType = mimeType, + bytes = bytes, + )) { + 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) + } + + is AppResult.Error -> { + messageDao.deleteMessage(tempId) + AppResult.Error(mediaResult.reason) + } + } + } catch (error: Throwable) { + messageDao.deleteMessage(tempId) + AppResult.Error(error.toAppError()) + } + } + + private fun mapMimeToMessageType(mimeType: String): String { + return when { + mimeType.startsWith("image/") -> "image" + mimeType.startsWith("video/") -> "video" + mimeType.startsWith("audio/") -> "audio" + else -> "file" + } + } + private fun Throwable.toAppError(): AppError { return when (this) { is IOException -> AppError.Network diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt index bd09b88..022dabc 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt @@ -16,6 +16,7 @@ import ru.daemonlord.messenger.core.network.AuthHeaderInterceptor import ru.daemonlord.messenger.core.network.TokenRefreshAuthenticator import ru.daemonlord.messenger.data.auth.api.AuthApiService import ru.daemonlord.messenger.data.chat.api.ChatApiService +import ru.daemonlord.messenger.data.media.api.MediaApiService import ru.daemonlord.messenger.data.message.api.MessageApiService import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import java.util.concurrent.TimeUnit @@ -130,4 +131,10 @@ object NetworkModule { fun provideMessageApiService(retrofit: Retrofit): MessageApiService { return retrofit.create(MessageApiService::class.java) } + + @Provides + @Singleton + fun provideMediaApiService(retrofit: Retrofit): MediaApiService { + return retrofit.create(MediaApiService::class.java) + } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt index 56a3961..7796f53 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt @@ -6,9 +6,11 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import ru.daemonlord.messenger.data.auth.repository.NetworkAuthRepository import ru.daemonlord.messenger.data.chat.repository.NetworkChatRepository +import ru.daemonlord.messenger.data.media.repository.NetworkMediaRepository import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository import ru.daemonlord.messenger.domain.auth.repository.AuthRepository import ru.daemonlord.messenger.domain.chat.repository.ChatRepository +import ru.daemonlord.messenger.domain.media.repository.MediaRepository import ru.daemonlord.messenger.domain.message.repository.MessageRepository import javax.inject.Singleton @@ -33,4 +35,10 @@ abstract class RepositoryModule { abstract fun bindMessageRepository( repository: NetworkMessageRepository, ): MessageRepository + + @Binds + @Singleton + abstract fun bindMediaRepository( + repository: NetworkMediaRepository, + ): MediaRepository } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/media/repository/MediaRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/media/repository/MediaRepository.kt new file mode 100644 index 0000000..4baf76c --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/media/repository/MediaRepository.kt @@ -0,0 +1,12 @@ +package ru.daemonlord.messenger.domain.media.repository + +import ru.daemonlord.messenger.domain.common.AppResult + +interface MediaRepository { + suspend fun uploadAndAttach( + messageId: Long, + fileName: String, + mimeType: String, + bytes: ByteArray, + ): AppResult +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/media/usecase/UploadAndAttachMediaUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/media/usecase/UploadAndAttachMediaUseCase.kt new file mode 100644 index 0000000..7a84db9 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/media/usecase/UploadAndAttachMediaUseCase.kt @@ -0,0 +1,23 @@ +package ru.daemonlord.messenger.domain.media.usecase + +import ru.daemonlord.messenger.domain.common.AppResult +import ru.daemonlord.messenger.domain.media.repository.MediaRepository +import javax.inject.Inject + +class UploadAndAttachMediaUseCase @Inject constructor( + private val mediaRepository: MediaRepository, +) { + suspend operator fun invoke( + messageId: Long, + fileName: String, + mimeType: String, + bytes: ByteArray, + ): AppResult { + return mediaRepository.uploadAndAttach( + messageId = messageId, + fileName = fileName, + mimeType = mimeType, + bytes = bytes, + ) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt index 9a1f304..0e9ef0a 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt @@ -9,6 +9,14 @@ interface MessageRepository { suspend fun syncRecentMessages(chatId: Long): AppResult suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult suspend fun sendTextMessage(chatId: Long, text: String, replyToMessageId: Long? = null): AppResult + suspend fun sendMediaMessage( + chatId: Long, + fileName: String, + mimeType: String, + bytes: ByteArray, + caption: String? = null, + replyToMessageId: Long? = null, + ): AppResult suspend fun editMessage(messageId: Long, newText: String): AppResult suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/SendMediaMessageUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/SendMediaMessageUseCase.kt new file mode 100644 index 0000000..d345fa2 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/SendMediaMessageUseCase.kt @@ -0,0 +1,27 @@ +package ru.daemonlord.messenger.domain.message.usecase + +import ru.daemonlord.messenger.domain.common.AppResult +import ru.daemonlord.messenger.domain.message.repository.MessageRepository +import javax.inject.Inject + +class SendMediaMessageUseCase @Inject constructor( + private val messageRepository: MessageRepository, +) { + suspend operator fun invoke( + chatId: Long, + fileName: String, + mimeType: String, + bytes: ByteArray, + caption: String? = null, + replyToMessageId: Long? = null, + ): AppResult { + return messageRepository.sendMediaMessage( + chatId = chatId, + fileName = fileName, + mimeType = mimeType, + bytes = bytes, + caption = caption, + replyToMessageId = replyToMessageId, + ) + } +} 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 60aea0e..f2ab562 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,5 +1,10 @@ package ru.daemonlord.messenger.ui.chat +import android.content.Context +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.combinedClickable import androidx.compose.foundation.ExperimentalFoundationApi @@ -19,6 +24,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -33,6 +39,18 @@ fun ChatRoute( viewModel: ChatViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val pickMediaLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + ) { uri -> + val picked = uri?.readMediaPayload(context) ?: return@rememberLauncherForActivityResult + viewModel.onMediaPicked( + fileName = picked.fileName, + mimeType = picked.mimeType, + bytes = picked.bytes, + ) + } + ChatScreen( state = state, onBack = onBack, @@ -44,6 +62,7 @@ fun ChatRoute( onDeleteSelected = viewModel::onDeleteSelected, onCancelComposeAction = viewModel::onCancelComposeAction, onLoadMore = viewModel::loadMore, + onPickMedia = { pickMediaLauncher.launch("*/*") }, ) } @@ -59,6 +78,7 @@ fun ChatScreen( onDeleteSelected: (Boolean) -> Unit, onCancelComposeAction: () -> Unit, onLoadMore: () -> Unit, + onPickMedia: () -> Unit, ) { Column( modifier = Modifier.fillMaxSize(), @@ -147,6 +167,12 @@ fun ChatScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { + Button( + onClick = onPickMedia, + enabled = !state.isUploadingMedia, + ) { + Text(if (state.isUploadingMedia) "..." else "Attach") + } OutlinedTextField( value = state.inputText, onValueChange = onInputChanged, @@ -156,7 +182,7 @@ fun ChatScreen( ) Button( onClick = onSendClick, - enabled = !state.isSending && state.inputText.isNotBlank(), + enabled = !state.isSending && !state.isUploadingMedia && state.inputText.isNotBlank(), ) { Text(if (state.isSending) "..." else "Send") } @@ -172,6 +198,34 @@ fun ChatScreen( } } +private data class PickedMediaPayload( + val fileName: String, + val mimeType: String, + val bytes: ByteArray, +) + +private fun Uri.readMediaPayload(context: Context): PickedMediaPayload? { + val resolver = context.contentResolver + val mime = resolver.getType(this)?.ifBlank { null } ?: "application/octet-stream" + var name = "attachment" + resolver.query(this, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0 && cursor.moveToFirst()) { + val raw = cursor.getString(nameIndex) + if (!raw.isNullOrBlank()) { + name = raw + } + } + } + val bytes = resolver.openInputStream(this)?.use { stream -> stream.readBytes() } ?: return null + if (bytes.isEmpty()) return null + return PickedMediaPayload( + fileName = name, + mimeType = mime, + bytes = bytes, + ) +} + @Composable @OptIn(ExperimentalFoundationApi::class) private fun MessageBubble( diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt index 1b90224..7e8936c 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt @@ -17,6 +17,7 @@ import ru.daemonlord.messenger.domain.message.usecase.DeleteMessageUseCase import ru.daemonlord.messenger.domain.message.usecase.EditMessageUseCase import ru.daemonlord.messenger.domain.message.usecase.LoadMoreMessagesUseCase import ru.daemonlord.messenger.domain.message.usecase.ObserveMessagesUseCase +import ru.daemonlord.messenger.domain.message.usecase.SendMediaMessageUseCase import ru.daemonlord.messenger.domain.message.usecase.SendTextMessageUseCase import ru.daemonlord.messenger.domain.message.usecase.SyncRecentMessagesUseCase import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase @@ -29,6 +30,7 @@ class ChatViewModel @Inject constructor( private val syncRecentMessagesUseCase: SyncRecentMessagesUseCase, private val loadMoreMessagesUseCase: LoadMoreMessagesUseCase, private val sendTextMessageUseCase: SendTextMessageUseCase, + private val sendMediaMessageUseCase: SendMediaMessageUseCase, private val editMessageUseCase: EditMessageUseCase, private val deleteMessageUseCase: DeleteMessageUseCase, private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase, @@ -133,6 +135,44 @@ class ChatViewModel @Inject constructor( } } + fun onMediaPicked( + fileName: String, + mimeType: String, + bytes: ByteArray, + ) { + if (bytes.isEmpty()) return + viewModelScope.launch { + _uiState.update { it.copy(isUploadingMedia = true, errorMessage = null) } + val caption = uiState.value.inputText.trim().ifBlank { null } + when ( + val result = sendMediaMessageUseCase( + chatId = chatId, + fileName = fileName, + mimeType = mimeType, + bytes = bytes, + caption = caption, + replyToMessageId = uiState.value.replyToMessage?.id, + ) + ) { + is AppResult.Success -> _uiState.update { + it.copy( + isUploadingMedia = false, + inputText = "", + replyToMessage = null, + editingMessage = null, + ) + } + + is AppResult.Error -> _uiState.update { + it.copy( + isUploadingMedia = false, + errorMessage = result.reason.toUiMessage(), + ) + } + } + } + } + fun loadMore() { val oldest = uiState.value.messages.firstOrNull() ?: return viewModelScope.launch { diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt index ef785cb..7cf5137 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt @@ -7,6 +7,7 @@ data class MessageUiState( val isLoading: Boolean = true, val isLoadingMore: Boolean = false, val isSending: Boolean = false, + val isUploadingMedia: Boolean = false, val errorMessage: String? = null, val messages: List = emptyList(), val inputText: String = "",