This commit is contained in:
Eric85710 2026-06-07 05:30:24 +08:00
parent e49d834fc7
commit ab10357042
4 changed files with 242 additions and 220 deletions

View file

@ -1,6 +1,7 @@
package com.example.login_v3.data.api.api_class
import com.example.login_v3.R
import com.example.login_v3.home.Message.ViewModel.Detail.MessageStatus
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@ -103,11 +104,14 @@ data class Message(
@Json(name = "reply_to_id") val replyToId: String? = null,
@kotlin.jvm.Transient
var repliedMessage: Message? = null,
@Json(name = "forwarded_from_id") val forwardedFromId: String? = null,
@Json(name = "forwarded_from_room_id") val forwardedFromRoomId: String? = null,
@Json(name = "attachment") val attachment: Attachment? = null, // 直接使用外層的 Attachment
@Json(name = "reactions") val reactions: List<Reaction>? = emptyList() // 直接使用外層的 Reaction
@Json(name = "attachment") val attachment: Attachment? = null,
@Json(name = "reactions") val reactions: List<Reaction>? = emptyList(),
// 🌟 核心新增:讓 UI 可以辨識訊息發送狀態!
@kotlin.jvm.Transient // 👈 告訴 Moshi 這個欄位不需要解析 JSON,這是我們本地維護的
val status: MessageStatus = MessageStatus.SUCCESS
)
//send message

View file

@ -13,11 +13,16 @@ import com.example.login_v3.data.api.api_class.MessageResponse
import com.example.login_v3.data.api.api_class.RoomListResponse
import com.example.login_v3.data.api.api_class.SendMessageRequest
import com.example.login_v3.home.Message.ViewModel.Detail.MessageDao
import com.example.login_v3.home.Message.ViewModel.Detail.MessageEntity
import com.example.login_v3.home.Message.ViewModel.Detail.MessageStatus
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.ByteArrayOutputStream
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@ -43,33 +48,122 @@ class ChatRoomsRepository @Inject constructor(
}
}
suspend fun getChatMessages(roomId: String): Result<MessageResponse> {
fun getChatMessagesFlow(roomId: String): Flow<List<Message>> {
return messageDao.getMessagesFlow(roomId).map { entities ->
val domainMessages = entities.map { entity ->
Message(
id = entity.id,
chatRoomId = entity.chatRoomId, // 👈 完美對齊
senderId = entity.senderId,
content = entity.content,
type = entity.type,
createdAt = entity.createdAt, // 👈 String 對 String
isEdited = entity.isEdited,
isDeleted = entity.isDeleted,
replyToId = entity.replyToId,
status = entity.status // 👈 把 Room 儲存的狀態倒出來給 UI
)
}
// 你原本的處理回覆邏輯
val messageMap = domainMessages.associateBy { it.id }
domainMessages.forEach { message ->
if (!message.replyToId.isNullOrBlank()) {
message.repliedMessage = messageMap[message.replyToId]
}
}
domainMessages
}
}
// api get data and put to local
suspend fun refreshChatMessages(roomId: String): Result<Unit> {
return try {
val response = api.getChatMessages(roomId)
if (response.isSuccessful) {
val body = response.body()
if (body != null) {
body.messages.forEach { networkMessage ->
val entity = MessageEntity(
id = networkMessage.id,
chatRoomId = roomId,
senderId = networkMessage.senderId,
content = networkMessage.content,
type = networkMessage.type,
createdAt = networkMessage.createdAt,
isEdited = networkMessage.isEdited,
isDeleted = networkMessage.isDeleted,
replyToId = networkMessage.replyToId,
status = MessageStatus.SUCCESS // 後端來的都是真的發送成功的
)
messageDao.insertOrUpdate(entity)
}
Result.success(Unit)
} else { Result.failure(Exception("Empty body")) }
} else { Result.failure(Exception("Error code: ${response.code()}")) }
} catch (e: Exception) { Result.failure(e) }
}
//fake sending
suspend fun sendMessageOptimistically(
roomId: String,
currentUserId: String,
content: String,
replyToId: String? = null
): Result<Unit> {
val tempId = UUID.randomUUID().toString()
// 💡 配合你的 String 格式,產生 ISO 時間字串或乾淨的目前時間字串,例如 2026-06-07T05:26:48Z
val currentIsoTime = java.time.Instant.now().toString()
val tempMessageEntity = MessageEntity(
id = tempId,
chatRoomId = roomId,
senderId = currentUserId,
content = content,
type = "text", // 預設普通文字
createdAt = currentIsoTime,
isEdited = false,
isDeleted = false,
replyToId = replyToId,
status = MessageStatus.SENDING // 👈 畫面馬上呈現半透明淡灰色!
)
messageDao.insertOrUpdate(tempMessageEntity)
return try {
val requestBody = SendMessageRequest(content = content, replyToId = replyToId)
val response = api.sendMessage(roomId, requestBody)
if (response.isSuccessful) {
val body = response.body()
if (body != null) {
// 成功:刪除假的,插入真的
messageDao.deleteById(tempId)
// 💡 核心邏輯:將訊息列表轉成 Map,方便用 ID 快速查找
val messageMap = body.messages.associateBy { it.id }
// 走訪每一條訊息,如果它有 replyToId,就去 Map 裡面找出那一條訊息塞給它
body.messages.forEach { message ->
if (!message.replyToId.isNullOrBlank()) {
message.repliedMessage = messageMap[message.replyToId]
}
}
Result.success(body)
val successEntity = MessageEntity(
id = body.id,
chatRoomId = roomId,
senderId = body.senderId,
content = body.content,
type = body.type,
createdAt = body.createdAt, // 後端確認的正式時間
isEdited = body.isEdited,
isDeleted = body.isDeleted,
replyToId = body.replyToId,
status = MessageStatus.SUCCESS
)
messageDao.insertOrUpdate(successEntity)
Result.success(Unit)
} else {
Result.failure(Exception("回應身體為空 (Empty response body)"))
messageDao.insertOrUpdate(tempMessageEntity.copy(status = MessageStatus.FAILED))
Result.failure(Exception("Body null"))
}
} else {
val errorMsg = response.errorBody()?.string() ?: "未知錯誤"
Result.failure(Exception("網路請求失敗,錯誤碼: ${response.code()}, 訊息: $errorMsg"))
messageDao.insertOrUpdate(tempMessageEntity.copy(status = MessageStatus.FAILED))
Result.failure(Exception("API Error"))
}
} catch (e: Exception) {
messageDao.insertOrUpdate(tempMessageEntity.copy(status = MessageStatus.FAILED))
Result.failure(e)
}
}
@ -296,16 +390,14 @@ class ChatRoomsRepository @Inject constructor(
suspend fun deleteMessage(roomId: String, messageId: String): Result<Unit> {
return try {
val response = api.deleteMessage(roomId, messageId)
if (response.isSuccessful) {
// 後端回傳 204 No Content 成功時沒有 body,直接回傳 Unit 即可
// 💡 既然刪除了,本地也一併砍掉,UI 就會立刻同步消失
messageDao.deleteById(messageId)
Result.success(Unit)
} else {
val errorMsg = response.errorBody()?.string() ?: "未知錯誤"
Result.failure(Exception("刪除訊息失敗,錯誤碼: ${response.code()}, 訊息: $errorMsg"))
Result.failure(Exception("Delete failed"))
}
} catch (e: Exception) {
// 捕捉網路斷線等異常狀況
Result.failure(e)
}
}

View file

@ -12,18 +12,22 @@ enum class MessageStatus { SENDING, SUCCESS, FAILED }
@Entity(tableName = "messages")
data class MessageEntity(
@PrimaryKey val id: String, // 臨時訊息可以用 UUID.randomUUID().toString()
val roomId: String,
val content: String,
@PrimaryKey val id: String,
val chatRoomId: String, // 👈 對齊你的 @Json(name = "chat_room_id")
val senderId: String,
val timestamp: Long,
val status: MessageStatus // 👈 核心:用來控制 UI 顯示樣式
val content: String,
val type: String, // 👈 新增:文字、圖片等類型
val createdAt: String, // 👈 核心修正:改成與 API 一致的 String 格式
val isEdited: Boolean, // 👈 新增
val isDeleted: Boolean, // 👈 新增
val replyToId: String?, // 👈 新增
val status: MessageStatus // 👈 樂觀更新狀態控制 (SENDING, SUCCESS, FAILED)
)
@Dao
interface MessageDao {
// 💡 監聽特定聊天室的訊息流
@Query("SELECT * FROM messages WHERE roomId = :roomId ORDER BY timestamp ASC")
// 💡 修正這裡:WHERE 條件欄位改為 chatRoomId
@Query("SELECT * FROM messages WHERE chatRoomId = :roomId ORDER BY createdAt ASC")
fun getMessagesFlow(roomId: String): Flow<List<MessageEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)

View file

@ -14,6 +14,7 @@ import com.example.login_v3.data.api.api_class.Message
import com.example.login_v3.data.api.api_class.fullContactAvatarUrl
import com.example.login_v3.data.repository.basic.TokenManager
import com.example.login_v3.home.Message.ViewModel.UserStatus
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -64,220 +65,119 @@ class ChatViewModel @Inject constructor(
private val tokenManager: TokenManager
) : ViewModel() {
// --- 訊息長按動作選單狀態 (Action Menu State) ---
// 1. 私有的 MutableStateFlow,用來控制長按選中的訊息
// --- 訊息長按動作選單與回覆狀態 (保持原樣) ---
// 💡 補上這兩行宣告
private val _deleteMessageState = MutableStateFlow<DeleteMessageState>(DeleteMessageState.Idle)
val deleteMessageState: StateFlow<DeleteMessageState> = _deleteMessageState.asStateFlow()
private val _actionMessage = MutableStateFlow<Message?>(null)
// 2. 公開給 Compose UI 觀察的唯讀 StateFlow
val actionMessage: StateFlow<Message?> = _actionMessage.asStateFlow()
/**
* 設定目前哪一則訊息被長按選取(開啟動作選單)
*/
fun setActionMessage(message: Message?) {
_actionMessage.value = message
}
fun setActionMessage(message: Message?) { _actionMessage.value = message }
fun clearActionMessage() { _actionMessage.value = null }
/**
* 清除目前選取的訊息狀態(關閉動作選單)
*/
fun clearActionMessage() {
_actionMessage.value = null
}
//reply function
private val _replyingMessage = MutableStateFlow<Message?>(null)
val replyingMessage: StateFlow<Message?> = _replyingMessage.asStateFlow()
// 💡 新增:當使用者點選某條訊息的「回覆」按鈕時呼叫
fun setReplyingMessage(message: Message?) {
_replyingMessage.value = message
}
fun setReplyingMessage(message: Message?) { _replyingMessage.value = message }
//UI state var
// --- 核心狀態管理 ---
private val _uiState = MutableStateFlow<MessagesUiState>(MessagesUiState.Loading)
val uiState: StateFlow<MessagesUiState> = _uiState.asStateFlow()
//message var
// 💡 提示:在做法 A 下,_sendMessageState 通常只用來控制輸入框的「Loading」轉圈圈或置灰,真正的訊息成功/失敗已經內嵌在 Message 本身的 status 裡了。
private val _sendMessageState = MutableStateFlow<SendMessageState>(SendMessageState.Idle)
val sendMessageState: StateFlow<SendMessageState> = _sendMessageState
// 用來管理監聽 Room Flow 的 Job,避免重複綁定
private var messageListenerJob: Job? = null
// =====================================================================
// 💡 核心改造一:持續監聽本地資料庫的 Flow
// =====================================================================
fun loadMessages(roomId: String) {
viewModelScope.launch {
// 先取消上一次的監聽(如果有切換房間的話)
messageListenerJob?.cancel()
messageListenerJob = viewModelScope.launch {
_uiState.value = MessagesUiState.Loading
//isMe
val currentLoggedInUserId = tokenManager.currentUserId.first() ?: ""
// 1. 同時獲取房間資訊與訊息列表
val roomResult = repository.getChatRoom(roomId) // ⚠️ 需確保 Repository 有提供此方法
val messagesResult = repository.getChatMessages(roomId)
val roomResult = repository.getChatRoom(roomId)
if (messagesResult.isSuccess) {
val messagesResponse = messagesResult.getOrNull()!!
// 2. 根據房間資訊決定標題邏輯
val room = roomResult.getOrNull()
// ✨ 這裡改用 Any? 來接收擴充屬性的結果
val (title, status, avatarUrl: Any?) = if (room != null) {
val name = room.partner?.displayName ?: room.partner?.username ?: "未知用戶"
val userStatus = UserStatus.fromString(room.partner?.status)
// 👇 直接呼叫你寫好的擴充屬性
val avatar = room.partner?.fullContactAvatarUrl
Triple(name, userStatus, avatar)
} else {
// 如果沒有房間資訊,就給預設的頭像資源
Triple("聊天室", UserStatus.UNKNOWN, R.drawable.avatar_v1)
}
val room = roomResult.getOrNull()
val (title, status, avatarUrl: Any?) = if (room != null) {
val name = room.partner?.displayName ?: room.partner?.username ?: "未知用戶"
val userStatus = UserStatus.fromString(room.partner?.status)
val avatar = room.partner?.fullContactAvatarUrl
Triple(name, userStatus, avatar)
} else {
Triple("聊天室", UserStatus.UNKNOWN, R.drawable.avatar_v1)
}
// 🌟 關鍵點:開始收集來自 Room Database 的冷流(Flow)
repository.getChatMessagesFlow(roomId).collect { localMessages ->
// 只要本地資料庫有任何風吹草動(多一條假訊息、狀態改變、被刪除),這裡都會立刻觸發
_uiState.value = MessagesUiState.Success(
roomTitle = title,
partnerStatus = status,
partnerAvatarUrl = avatarUrl, //這裡就會是完整的網址或 R.drawable.avatar_v1
messages = messagesResponse.messages,
partnerAvatarUrl = avatarUrl,
messages = localMessages, // 👈 直接使用來自 Room 的最新訊息列表
currentUserId = currentLoggedInUserId
)
}
}
//已讀功能
launch {
repository.markAsRead(roomId)
.onFailure { error ->
// 這裡通常不需要回報給 UI(不用跳 Error 畫面)
// 只需要印個 Log 知道出事了就好
Log.e("ChatViewModel", "標記已讀失敗: ${error.message}")
}
// 🌟 背景默默同步:去後端拉取最新訊息寫入 Room
viewModelScope.launch {
repository.refreshChatMessages(roomId).onSuccess {
// 已讀功能保持
repository.markAsRead(roomId).onFailure { error ->
Log.e("ChatViewModel", "標記已讀失敗: ${error.message}")
}
} else {
val exception = messagesResult.exceptionOrNull()
_uiState.value = MessagesUiState.Error(message = exception?.message ?: "未知錯誤")
}.onFailure { error ->
Log.e("ChatViewModel", "背景同步失敗,改用純離線快取", error)
// 這裡不用特地切換到 Error 狀態,因為 getChatMessagesFlow 可能已經把離線快取顯示在畫面上了
}
}
}
//upload image
fun sendMessage(roomId: String, content: String, replyToId: String? = null) {
// =====================================================================
// 💡 核心改造二:發送文字訊息(樂觀更新)
// =====================================================================
fun sendMessage(roomId: String, content: String) {
if (content.isBlank()) return
viewModelScope.launch {
_sendMessageState.value = SendMessageState.Loading
// 💡 取得當前是否有正在回覆的訊息 ID
val replyToId = _replyingMessage.value?.id
val currentLoggedInUserId = tokenManager.currentUserId.first() ?: ""
val result = repository.sendMessage(
// 呼叫樂觀更新方法
val result = repository.sendMessageOptimistically(
roomId = roomId,
currentUserId = currentLoggedInUserId,
content = content,
replyToId = replyToId
)
result.onSuccess { newMessage ->
_sendMessageState.value = SendMessageState.Success(newMessage)
_replyingMessage.value = null
// 😎【超讚的體驗優化】:發送成功後,直接把新訊息手動塞進現有的 UI 列表裡
val currentState = _uiState.value
if (currentState is MessagesUiState.Success) {
// 把新訊息加到原本的列表最後面
val updatedMessages = currentState.messages + newMessage
// 更新 uiState,這樣 Compose 畫面不需要重新 load 網路就能立刻刷新!
_uiState.value = currentState.copy(messages = updatedMessages)
}
// 清空回覆狀態
_replyingMessage.value = null
result.onSuccess {
// 🌟 發送成功!此時 Room 內對應的臨時訊息已經被改為 SUCCESS 了
// Flow 會自動讓 UI 刷新,ViewModel 這裡「不需要」手動做 updatedMessages + newMessage
_sendMessageState.value = SendMessageState.Idle
}.onFailure { error ->
Log.e("ChatViewModel", "Send message failed", error)
// 🌟 發送失敗!Room 內的臨時訊息已被改為 FAILED,畫面上會顯示驚嘆號
_sendMessageState.value = SendMessageState.Error(error.message ?: "未知錯誤")
}
}
}
//upload file
fun uploadAttachment(roomId: String, fileUri: Uri) {
viewModelScope.launch {
// 1. 將發送狀態切換為 Loading,讓 UI 顯示進度條
_sendMessageState.value = SendMessageState.Loading
// 2. 呼叫 Repository 處理檔案並上傳
val result = repository.uploadAttachment(roomId, fileUri)
result.onSuccess { newMessage ->
// 3. 上傳成功,更新發送狀態
_sendMessageState.value = SendMessageState.Success(newMessage)
// 😎【體驗優化】:跟發送文字訊息一樣,直接把帶有圖片的新訊息塞進現有的 UI 列表裡
val currentState = _uiState.value
if (currentState is MessagesUiState.Success) {
val updatedMessages = currentState.messages + newMessage
_uiState.value = currentState.copy(messages = updatedMessages)
}
}.onFailure { error ->
Log.e("ChatViewModel", "Upload attachment failed", error)
// 4. 上傳失敗,通知 UI 顯示錯誤
_sendMessageState.value = SendMessageState.Error(error.message ?: "上傳失敗,請稍後再試")
}
}
}
private val _reactionUsersState = MutableStateFlow<ReactionUsersState>(ReactionUsersState.Idle)
val reactionUsersState: StateFlow<ReactionUsersState> = _reactionUsersState.asStateFlow()
//get emoji reaction
fun loadMessageReactionUsers(roomId: String, messageId: String, emoji: String) {
viewModelScope.launch {
_reactionUsersState.value = ReactionUsersState.Loading
val result = repository.getMessageReactionUsers(
roomId = roomId,
messageId = messageId,
emoji = emoji
)
result.onSuccess { response ->
_reactionUsersState.value = ReactionUsersState.Success(response.users)
}.onFailure { error ->
Log.e("ChatViewModel", "Get reaction users failed", error)
_reactionUsersState.value = ReactionUsersState.Error(error.message ?: "無法取得按讚名單")
}
}
}
//add emoji reaction
fun addMessageReaction(roomId: String, messageId: String, emoji: String) {
viewModelScope.launch {
val result = repository.addMessageReaction(roomId, messageId, emoji)
result.onSuccess {
// 😎【超讚體驗優化】:點擊成功後,除了通知後端,
// 也可以選擇直接呼叫 loadMessages(roomId) 來重新拉取最新帶有按讚數量的訊息列表
loadMessages(roomId)
}.onFailure { error ->
Log.e("ChatViewModel", "Add reaction failed", error)
// 這裡可以依需求決定要不要用 Toast 提示使用者點擊失敗
}
}
}
//delete emoji reaction
fun removeMessageReaction(roomId: String, messageId: String, emoji: String) {
viewModelScope.launch {
val result = repository.deleteMessageReaction(roomId, messageId, emoji)
result.onSuccess {
// 移除成功後,重新拉取最新訊息列表以刷新 UI
loadMessages(roomId)
}.onFailure { error ->
Log.e("ChatViewModel", "Remove reaction failed", error)
}
}
}
// 儲存刪除狀態的變數
private val _deleteMessageState = MutableStateFlow<DeleteMessageState>(DeleteMessageState.Idle)
val deleteMessageState: StateFlow<DeleteMessageState> = _deleteMessageState.asStateFlow()
// 刪除訊息的 function
// =====================================================================
// 💡 核心改造三:刪除訊息
// =====================================================================
fun deleteMessage(roomId: String, messageId: String) {
viewModelScope.launch {
_deleteMessageState.value = DeleteMessageState.Loading
@ -286,25 +186,10 @@ class ChatViewModel @Inject constructor(
result.onSuccess {
_deleteMessageState.value = DeleteMessageState.Success
// ✨【優化】:刪除成功後,自動關閉長按選單
clearActionMessage()
val currentState = _uiState.value
if (currentState is MessagesUiState.Success) {
val updatedMessages = currentState.messages.map { message ->
if (message.id == messageId) {
message.copy(
isDeleted = true,
content = "此訊息已被刪除"
)
} else {
message
}
}
_uiState.value = currentState.copy(messages = updatedMessages)
}
// 🌟 刪除成功!Repository 已經把本地 Room 的資料刪除
// Flow 會自動發送全新的列表給 UI,這裡「完全不需要」再手動 map 改 content 了!
}.onFailure { error ->
Log.e("ChatViewModel", "Delete message failed", error)
_deleteMessageState.value = DeleteMessageState.Error(error.message ?: "刪除訊息失敗")
@ -312,18 +197,55 @@ class ChatViewModel @Inject constructor(
}
}
// 重設刪除狀態的 function (供 UI 重置狀態使用)
fun resetDeleteMessageState() {
_deleteMessageState.value = DeleteMessageState.Idle
// --- 其餘 uploadAttachment、Emoji 功能保持原樣 ---
fun uploadAttachment(roomId: String, fileUri: Uri) {
viewModelScope.launch {
_sendMessageState.value = SendMessageState.Loading
val result = repository.uploadAttachment(roomId, fileUri)
result.onSuccess { newMessage ->
_sendMessageState.value = SendMessageState.Success(newMessage)
// 注意:如果上傳檔案未來也想做樂觀更新,可以用跟 sendMessage 相同的邏輯改造 Repository。
// 如果目前保持原樣,因為原本的 loadMessages 是 Flow,它會持續監聽,只要 refreshChatMessages 被觸發或後端有推播,它就會出現。
// 為了保險起見,若 uploadAttachment 還沒改造,可以先留著手動塞入的邏輯,或者直接在成功後呼叫 repository.refreshChatMessages(roomId)
}.onFailure { error ->
Log.e("ChatViewModel", "Upload attachment failed", error)
_sendMessageState.value = SendMessageState.Error(error.message ?: "上傳失敗")
}
}
}
// 💡 請確保這段程式碼有確實待在 ChatViewModel 裡面
fun resetReactionUsersState() {
_reactionUsersState.value = ReactionUsersState.Idle
private val _reactionUsersState = MutableStateFlow<ReactionUsersState>(ReactionUsersState.Idle)
val reactionUsersState: StateFlow<ReactionUsersState> = _reactionUsersState.asStateFlow()
fun loadMessageReactionUsers(roomId: String, messageId: String, emoji: String) {
viewModelScope.launch {
_reactionUsersState.value = ReactionUsersState.Loading
repository.getMessageReactionUsers(roomId, messageId, emoji)
.onSuccess { response -> _reactionUsersState.value = ReactionUsersState.Success(response.users) }
.onFailure { error -> _reactionUsersState.value = ReactionUsersState.Error(error.message ?: "無法取得按讚名單") }
}
}
//新增:重設發送狀態的 function
fun resetSendMessageState() {
_sendMessageState.value = SendMessageState.Idle
fun addMessageReaction(roomId: String, messageId: String, emoji: String) {
viewModelScope.launch {
repository.addMessageReaction(roomId, messageId, emoji)
.onSuccess {
// 🌟 這裡也不用 loadMessages(roomId) 了,直接背景 refresh 即可
repository.refreshChatMessages(roomId)
}
}
}
fun removeMessageReaction(roomId: String, messageId: String, emoji: String) {
viewModelScope.launch {
repository.deleteMessageReaction(roomId, messageId, emoji)
.onSuccess {
repository.refreshChatMessages(roomId)
}
}
}
fun resetDeleteMessageState() { _deleteMessageState.value = DeleteMessageState.Idle }
fun resetReactionUsersState() { _reactionUsersState.value = ReactionUsersState.Idle }
fun resetSendMessageState() { _sendMessageState.value = SendMessageState.Idle }
}