android: add media upload repository and chat attachment send flow
Some checks are pending
CI / test (push) Has started running

This commit is contained in:
Codex
2026-03-09 02:22:48 +03:00
parent ad2e0ede42
commit 8d13eb104e
15 changed files with 418 additions and 2 deletions

View File

@@ -97,3 +97,10 @@
- Added DAO test for message scoped replace behavior in Room. - Added DAO test for message scoped replace behavior in Room.
- Expanded realtime parser tests with rich `receive_message` mapping coverage. - Expanded realtime parser tests with rich `receive_message` mapping coverage.
- Updated `docs/android-checklist.md` for completed message-core items. - 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.

View File

@@ -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,
)
}

View File

@@ -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<String, String> = 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,
)

View File

@@ -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<Unit> = 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)
}
}
}

View File

@@ -27,7 +27,7 @@ data class MessageCreateRequestDto(
@SerialName("chat_id") @SerialName("chat_id")
val chatId: Long, val chatId: Long,
val type: String, val type: String,
val text: String, val text: String? = null,
@SerialName("client_message_id") @SerialName("client_message_id")
val clientMessageId: String, val clientMessageId: String,
@SerialName("reply_to_message_id") @SerialName("reply_to_message_id")

View File

@@ -12,6 +12,7 @@ import ru.daemonlord.messenger.data.message.mapper.toEntity
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
import ru.daemonlord.messenger.domain.media.repository.MediaRepository
import ru.daemonlord.messenger.domain.message.model.MessageItem import ru.daemonlord.messenger.domain.message.model.MessageItem
import ru.daemonlord.messenger.domain.message.repository.MessageRepository import ru.daemonlord.messenger.domain.message.repository.MessageRepository
import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto
@@ -28,6 +29,7 @@ class NetworkMessageRepository @Inject constructor(
private val messageApiService: MessageApiService, private val messageApiService: MessageApiService,
private val messageDao: MessageDao, private val messageDao: MessageDao,
private val chatDao: ChatDao, private val chatDao: ChatDao,
private val mediaRepository: MediaRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : MessageRepository { ) : 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<Unit> = 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 { private fun Throwable.toAppError(): AppError {
return when (this) { return when (this) {
is IOException -> AppError.Network is IOException -> AppError.Network

View File

@@ -16,6 +16,7 @@ import ru.daemonlord.messenger.core.network.AuthHeaderInterceptor
import ru.daemonlord.messenger.core.network.TokenRefreshAuthenticator import ru.daemonlord.messenger.core.network.TokenRefreshAuthenticator
import ru.daemonlord.messenger.data.auth.api.AuthApiService import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.chat.api.ChatApiService 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 ru.daemonlord.messenger.data.message.api.MessageApiService
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -130,4 +131,10 @@ object NetworkModule {
fun provideMessageApiService(retrofit: Retrofit): MessageApiService { fun provideMessageApiService(retrofit: Retrofit): MessageApiService {
return retrofit.create(MessageApiService::class.java) return retrofit.create(MessageApiService::class.java)
} }
@Provides
@Singleton
fun provideMediaApiService(retrofit: Retrofit): MediaApiService {
return retrofit.create(MediaApiService::class.java)
}
} }

View File

@@ -6,9 +6,11 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import ru.daemonlord.messenger.data.auth.repository.NetworkAuthRepository import ru.daemonlord.messenger.data.auth.repository.NetworkAuthRepository
import ru.daemonlord.messenger.data.chat.repository.NetworkChatRepository 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.data.message.repository.NetworkMessageRepository
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository 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 ru.daemonlord.messenger.domain.message.repository.MessageRepository
import javax.inject.Singleton import javax.inject.Singleton
@@ -33,4 +35,10 @@ abstract class RepositoryModule {
abstract fun bindMessageRepository( abstract fun bindMessageRepository(
repository: NetworkMessageRepository, repository: NetworkMessageRepository,
): MessageRepository ): MessageRepository
@Binds
@Singleton
abstract fun bindMediaRepository(
repository: NetworkMediaRepository,
): MediaRepository
} }

View File

@@ -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<Unit>
}

View File

@@ -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<Unit> {
return mediaRepository.uploadAndAttach(
messageId = messageId,
fileName = fileName,
mimeType = mimeType,
bytes = bytes,
)
}
}

View File

@@ -9,6 +9,14 @@ interface MessageRepository {
suspend fun syncRecentMessages(chatId: Long): AppResult<Unit> suspend fun syncRecentMessages(chatId: Long): AppResult<Unit>
suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult<Unit> suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult<Unit>
suspend fun sendTextMessage(chatId: Long, text: String, replyToMessageId: Long? = null): AppResult<Unit> suspend fun sendTextMessage(chatId: Long, text: String, replyToMessageId: Long? = null): AppResult<Unit>
suspend fun sendMediaMessage(
chatId: Long,
fileName: String,
mimeType: String,
bytes: ByteArray,
caption: String? = null,
replyToMessageId: Long? = null,
): AppResult<Unit>
suspend fun editMessage(messageId: Long, newText: String): AppResult<Unit> suspend fun editMessage(messageId: Long, newText: String): AppResult<Unit>
suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult<Unit> suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult<Unit>
} }

View File

@@ -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<Unit> {
return messageRepository.sendMediaMessage(
chatId = chatId,
fileName = fileName,
mimeType = mimeType,
bytes = bytes,
caption = caption,
replyToMessageId = replyToMessageId,
)
}
}

View File

@@ -1,5 +1,10 @@
package ru.daemonlord.messenger.ui.chat 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.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
@@ -19,6 +24,7 @@ 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.getValue import androidx.compose.runtime.getValue
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
@@ -33,6 +39,18 @@ fun ChatRoute(
viewModel: ChatViewModel = hiltViewModel(), viewModel: ChatViewModel = hiltViewModel(),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() 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( ChatScreen(
state = state, state = state,
onBack = onBack, onBack = onBack,
@@ -44,6 +62,7 @@ fun ChatRoute(
onDeleteSelected = viewModel::onDeleteSelected, onDeleteSelected = viewModel::onDeleteSelected,
onCancelComposeAction = viewModel::onCancelComposeAction, onCancelComposeAction = viewModel::onCancelComposeAction,
onLoadMore = viewModel::loadMore, onLoadMore = viewModel::loadMore,
onPickMedia = { pickMediaLauncher.launch("*/*") },
) )
} }
@@ -59,6 +78,7 @@ fun ChatScreen(
onDeleteSelected: (Boolean) -> Unit, onDeleteSelected: (Boolean) -> Unit,
onCancelComposeAction: () -> Unit, onCancelComposeAction: () -> Unit,
onLoadMore: () -> Unit, onLoadMore: () -> Unit,
onPickMedia: () -> Unit,
) { ) {
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -147,6 +167,12 @@ fun ChatScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Button(
onClick = onPickMedia,
enabled = !state.isUploadingMedia,
) {
Text(if (state.isUploadingMedia) "..." else "Attach")
}
OutlinedTextField( OutlinedTextField(
value = state.inputText, value = state.inputText,
onValueChange = onInputChanged, onValueChange = onInputChanged,
@@ -156,7 +182,7 @@ fun ChatScreen(
) )
Button( Button(
onClick = onSendClick, onClick = onSendClick,
enabled = !state.isSending && state.inputText.isNotBlank(), enabled = !state.isSending && !state.isUploadingMedia && state.inputText.isNotBlank(),
) { ) {
Text(if (state.isSending) "..." else "Send") 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 @Composable
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
private fun MessageBubble( private fun MessageBubble(

View File

@@ -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.EditMessageUseCase
import ru.daemonlord.messenger.domain.message.usecase.LoadMoreMessagesUseCase import ru.daemonlord.messenger.domain.message.usecase.LoadMoreMessagesUseCase
import ru.daemonlord.messenger.domain.message.usecase.ObserveMessagesUseCase 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.SendTextMessageUseCase
import ru.daemonlord.messenger.domain.message.usecase.SyncRecentMessagesUseCase import ru.daemonlord.messenger.domain.message.usecase.SyncRecentMessagesUseCase
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
@@ -29,6 +30,7 @@ class ChatViewModel @Inject constructor(
private val syncRecentMessagesUseCase: SyncRecentMessagesUseCase, private val syncRecentMessagesUseCase: SyncRecentMessagesUseCase,
private val loadMoreMessagesUseCase: LoadMoreMessagesUseCase, private val loadMoreMessagesUseCase: LoadMoreMessagesUseCase,
private val sendTextMessageUseCase: SendTextMessageUseCase, private val sendTextMessageUseCase: SendTextMessageUseCase,
private val sendMediaMessageUseCase: SendMediaMessageUseCase,
private val editMessageUseCase: EditMessageUseCase, private val editMessageUseCase: EditMessageUseCase,
private val deleteMessageUseCase: DeleteMessageUseCase, private val deleteMessageUseCase: DeleteMessageUseCase,
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase, 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() { fun loadMore() {
val oldest = uiState.value.messages.firstOrNull() ?: return val oldest = uiState.value.messages.firstOrNull() ?: return
viewModelScope.launch { viewModelScope.launch {

View File

@@ -7,6 +7,7 @@ data class MessageUiState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val isLoadingMore: Boolean = false, val isLoadingMore: Boolean = false,
val isSending: Boolean = false, val isSending: Boolean = false,
val isUploadingMedia: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val messages: List<MessageItem> = emptyList(), val messages: List<MessageItem> = emptyList(),
val inputText: String = "", val inputText: String = "",