android: complete message core with forward reactions and read states
Some checks failed
CI / test (push) Failing after 2m10s

This commit is contained in:
Codex
2026-03-09 12:48:51 +03:00
parent 3dd320193c
commit 946b85a18f
21 changed files with 576 additions and 10 deletions

View File

@@ -108,3 +108,12 @@
### Step 17 - Sprint B / media tests
- Added `NetworkMediaRepositoryTest` for successful upload+attach flow.
- Added error-path coverage for failed presigned upload handling.
## 2026-03-09
### Step 18 - Sprint P0 / 1) Message core completion
- Extended message API/data contracts with `messages/status`, `forward`, and reaction endpoints.
- Added message domain support for forwarded message metadata and attachment waveform payload.
- Implemented repository operations for delivery/read acknowledgements, forward, and reactions.
- Updated Chat ViewModel/UI with forward flow, reaction toggle, and edit/delete-for-all edge-case guards.
- Added automatic delivered/read acknowledgement for latest incoming message in active chat.
- Fixed outgoing message detection by resolving current user id from JWT `sub` claim.

View File

@@ -16,7 +16,7 @@ import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
MessageEntity::class,
MessageAttachmentEntity::class,
],
version = 2,
version = 3,
exportSchema = false,
)
abstract class MessengerDatabase : RoomDatabase() {

View File

@@ -8,7 +8,11 @@ import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageForwardRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageReadDto
import ru.daemonlord.messenger.data.message.dto.MessageReactionDto
import ru.daemonlord.messenger.data.message.dto.MessageReactionToggleRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageStatusUpdateRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageUpdateRequestDto
interface MessageApiService {
@@ -35,4 +39,26 @@ interface MessageApiService {
@Path("message_id") messageId: Long,
@Query("for_all") forAll: Boolean = false,
)
@POST("/api/v1/messages/status")
suspend fun updateMessageStatus(
@Body request: MessageStatusUpdateRequestDto,
)
@POST("/api/v1/messages/{message_id}/forward")
suspend fun forwardMessage(
@Path("message_id") messageId: Long,
@Body request: MessageForwardRequestDto,
): MessageReadDto
@GET("/api/v1/messages/{message_id}/reactions")
suspend fun listReactions(
@Path("message_id") messageId: Long,
): List<MessageReactionDto>
@POST("/api/v1/messages/{message_id}/reactions/toggle")
suspend fun toggleReaction(
@Path("message_id") messageId: Long,
@Body request: MessageReactionToggleRequestDto,
): List<MessageReactionDto>
}

View File

@@ -12,10 +12,14 @@ data class MessageReadDto(
val senderId: Long,
@SerialName("reply_to_message_id")
val replyToMessageId: Long? = null,
@SerialName("forwarded_from_message_id")
val forwardedFromMessageId: Long? = null,
val type: String,
val text: String? = null,
@SerialName("delivery_status")
val deliveryStatus: String? = null,
@SerialName("attachment_waveform")
val attachmentWaveform: List<Int>? = null,
@SerialName("created_at")
val createdAt: String,
@SerialName("updated_at")
@@ -38,3 +42,32 @@ data class MessageCreateRequestDto(
data class MessageUpdateRequestDto(
val text: String,
)
@Serializable
data class MessageStatusUpdateRequestDto(
@SerialName("chat_id")
val chatId: Long,
@SerialName("message_id")
val messageId: Long,
val status: String,
)
@Serializable
data class MessageForwardRequestDto(
@SerialName("target_chat_id")
val targetChatId: Long,
@SerialName("include_author")
val includeAuthor: Boolean = true,
)
@Serializable
data class MessageReactionDto(
val emoji: String,
val count: Int,
val reacted: Boolean = false,
)
@Serializable
data class MessageReactionToggleRequestDto(
val emoji: String,
)

View File

@@ -29,12 +29,16 @@ data class MessageEntity(
val senderAvatarUrl: String?,
@ColumnInfo(name = "reply_to_message_id")
val replyToMessageId: Long?,
@ColumnInfo(name = "forwarded_from_message_id")
val forwardedFromMessageId: Long?,
@ColumnInfo(name = "type")
val type: String,
@ColumnInfo(name = "text")
val text: String?,
@ColumnInfo(name = "status")
val status: String?,
@ColumnInfo(name = "attachment_waveform_json")
val attachmentWaveformJson: String?,
@ColumnInfo(name = "created_at")
val createdAt: String,
@ColumnInfo(name = "updated_at")

View File

@@ -1,9 +1,14 @@
package ru.daemonlord.messenger.data.message.mapper
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import ru.daemonlord.messenger.data.message.dto.MessageReadDto
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.domain.message.model.MessageReaction
import ru.daemonlord.messenger.domain.message.model.MessageItem
private val mapperJson = Json { ignoreUnknownKeys = true }
fun MessageReadDto.toEntity(): MessageEntity {
return MessageEntity(
id = id,
@@ -13,9 +18,11 @@ fun MessageReadDto.toEntity(): MessageEntity {
senderUsername = null,
senderAvatarUrl = null,
replyToMessageId = replyToMessageId,
forwardedFromMessageId = forwardedFromMessageId,
type = type,
text = text,
status = deliveryStatus,
attachmentWaveformJson = attachmentWaveform?.let { mapperJson.encodeToString(it) },
createdAt = createdAt,
updatedAt = updatedAt,
)
@@ -34,5 +41,20 @@ fun MessageEntity.toDomain(currentUserId: Long?): MessageItem {
isOutgoing = currentUserId != null && currentUserId == senderId,
status = status,
replyToMessageId = replyToMessageId,
forwardedFromMessageId = forwardedFromMessageId,
attachmentWaveform = attachmentWaveformJson.toWaveformOrNull(),
)
}
fun ru.daemonlord.messenger.data.message.dto.MessageReactionDto.toDomain(): MessageReaction {
return MessageReaction(
emoji = emoji,
count = count,
reacted = reacted,
)
}
private fun String?.toWaveformOrNull(): List<Int>? {
if (this.isNullOrBlank()) return null
return runCatching { mapperJson.decodeFromString<List<Int>>(this) }.getOrNull()
}

View File

@@ -1,10 +1,17 @@
package ru.daemonlord.messenger.data.message.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
import retrofit2.HttpException
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
import ru.daemonlord.messenger.data.message.api.MessageApiService
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.data.message.mapper.toDomain
@@ -14,15 +21,22 @@ 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.model.MessageReaction
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageForwardRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageReactionToggleRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageStatusUpdateRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageUpdateRequestDto
import java.io.IOException
import java.util.Base64
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@Singleton
class NetworkMessageRepository @Inject constructor(
@@ -30,10 +44,21 @@ class NetworkMessageRepository @Inject constructor(
private val messageDao: MessageDao,
private val chatDao: ChatDao,
private val mediaRepository: MediaRepository,
tokenRepository: TokenRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : MessageRepository {
private val currentUserId: Long? = null
@Volatile
private var currentUserId: Long? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
init {
scope.launch {
tokenRepository.observeTokens().collectLatest { tokens ->
currentUserId = tokens?.accessToken?.extractUserIdFromJwt()
}
}
}
override fun observeMessages(chatId: Long): Flow<List<MessageItem>> {
return messageDao.observeRecentMessages(chatId = chatId).map { entities ->
@@ -85,9 +110,11 @@ class NetworkMessageRepository @Inject constructor(
senderUsername = null,
senderAvatarUrl = null,
replyToMessageId = replyToMessageId,
forwardedFromMessageId = null,
type = "text",
text = text,
status = "pending",
attachmentWaveformJson = null,
createdAt = java.time.Instant.now().toString(),
updatedAt = null,
)
@@ -169,9 +196,11 @@ class NetworkMessageRepository @Inject constructor(
senderUsername = null,
senderAvatarUrl = null,
replyToMessageId = replyToMessageId,
forwardedFromMessageId = null,
type = messageType,
text = caption,
status = "pending",
attachmentWaveformJson = null,
createdAt = java.time.Instant.now().toString(),
updatedAt = null,
)
@@ -224,6 +253,84 @@ class NetworkMessageRepository @Inject constructor(
}
}
override suspend fun markMessageDelivered(chatId: Long, messageId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
messageApiService.updateMessageStatus(
request = MessageStatusUpdateRequestDto(
chatId = chatId,
messageId = messageId,
status = "message_delivered",
)
)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun markMessageRead(chatId: Long, messageId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
messageApiService.updateMessageStatus(
request = MessageStatusUpdateRequestDto(
chatId = chatId,
messageId = messageId,
status = "message_read",
)
)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun forwardMessage(
messageId: Long,
targetChatId: Long,
includeAuthor: Boolean,
): AppResult<Unit> = withContext(ioDispatcher) {
try {
val forwarded = messageApiService.forwardMessage(
messageId = messageId,
request = MessageForwardRequestDto(
targetChatId = targetChatId,
includeAuthor = includeAuthor,
),
)
messageDao.upsertMessages(listOf(forwarded.toEntity()))
chatDao.updateLastMessage(
chatId = targetChatId,
lastMessageText = forwarded.text,
lastMessageType = forwarded.type,
lastMessageCreatedAt = forwarded.createdAt,
updatedSortAt = forwarded.createdAt,
)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun listReactions(messageId: Long): AppResult<List<MessageReaction>> = withContext(ioDispatcher) {
try {
val reactions = messageApiService.listReactions(messageId = messageId).map { it.toDomain() }
AppResult.Success(reactions)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun toggleReaction(messageId: Long, emoji: String): AppResult<List<MessageReaction>> = withContext(ioDispatcher) {
try {
val reactions = messageApiService.toggleReaction(
messageId = messageId,
request = MessageReactionToggleRequestDto(emoji = emoji),
).map { it.toDomain() }
AppResult.Success(reactions)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
private fun mapMimeToMessageType(mimeType: String): String {
return when {
mimeType.startsWith("image/") -> "image"
@@ -244,4 +351,24 @@ class NetworkMessageRepository @Inject constructor(
else -> AppError.Unknown(cause = this)
}
}
private fun String.extractUserIdFromJwt(): Long? {
val payloadPart = split('.').getOrNull(1) ?: return null
val normalized = payloadPart
.replace('-', '+')
.replace('_', '/')
.let { part ->
when (part.length % 4) {
2 -> "$part=="
3 -> "$part="
else -> part
}
}
val payloadJson = runCatching {
String(Base64.getDecoder().decode(normalized), Charsets.UTF_8)
}.getOrNull() ?: return null
return runCatching {
Json.parseToJsonElement(payloadJson).jsonObject["sub"]?.jsonPrimitive?.content?.toLongOrNull()
}.getOrNull()
}
}

View File

@@ -12,4 +12,6 @@ data class MessageItem(
val isOutgoing: Boolean,
val status: String?,
val replyToMessageId: Long?,
val forwardedFromMessageId: Long?,
val attachmentWaveform: List<Int>?,
)

View File

@@ -0,0 +1,7 @@
package ru.daemonlord.messenger.domain.message.model
data class MessageReaction(
val emoji: String,
val count: Int,
val reacted: Boolean,
)

View File

@@ -3,6 +3,7 @@ package ru.daemonlord.messenger.domain.message.repository
import kotlinx.coroutines.flow.Flow
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.message.model.MessageItem
import ru.daemonlord.messenger.domain.message.model.MessageReaction
interface MessageRepository {
fun observeMessages(chatId: Long): Flow<List<MessageItem>>
@@ -19,4 +20,9 @@ interface MessageRepository {
): AppResult<Unit>
suspend fun editMessage(messageId: Long, newText: String): AppResult<Unit>
suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult<Unit>
suspend fun markMessageDelivered(chatId: Long, messageId: Long): AppResult<Unit>
suspend fun markMessageRead(chatId: Long, messageId: Long): AppResult<Unit>
suspend fun forwardMessage(messageId: Long, targetChatId: Long, includeAuthor: Boolean = true): AppResult<Unit>
suspend fun listReactions(messageId: Long): AppResult<List<MessageReaction>>
suspend fun toggleReaction(messageId: Long, emoji: String): AppResult<List<MessageReaction>>
}

View File

@@ -0,0 +1,17 @@
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 ForwardMessageUseCase @Inject constructor(
private val repository: MessageRepository,
) {
suspend operator fun invoke(messageId: Long, targetChatId: Long, includeAuthor: Boolean = true): AppResult<Unit> {
return repository.forwardMessage(
messageId = messageId,
targetChatId = targetChatId,
includeAuthor = includeAuthor,
)
}
}

View File

@@ -0,0 +1,14 @@
package ru.daemonlord.messenger.domain.message.usecase
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.message.model.MessageReaction
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
import javax.inject.Inject
class ListMessageReactionsUseCase @Inject constructor(
private val repository: MessageRepository,
) {
suspend operator fun invoke(messageId: Long): AppResult<List<MessageReaction>> {
return repository.listReactions(messageId = messageId)
}
}

View File

@@ -0,0 +1,13 @@
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 MarkMessageDeliveredUseCase @Inject constructor(
private val repository: MessageRepository,
) {
suspend operator fun invoke(chatId: Long, messageId: Long): AppResult<Unit> {
return repository.markMessageDelivered(chatId = chatId, messageId = messageId)
}
}

View File

@@ -0,0 +1,13 @@
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 MarkMessageReadUseCase @Inject constructor(
private val repository: MessageRepository,
) {
suspend operator fun invoke(chatId: Long, messageId: Long): AppResult<Unit> {
return repository.markMessageRead(chatId = chatId, messageId = messageId)
}
}

View File

@@ -0,0 +1,14 @@
package ru.daemonlord.messenger.domain.message.usecase
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.message.model.MessageReaction
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
import javax.inject.Inject
class ToggleMessageReactionUseCase @Inject constructor(
private val repository: MessageRepository,
) {
suspend operator fun invoke(messageId: Long, emoji: String): AppResult<List<MessageReaction>> {
return repository.toggleReaction(messageId = messageId, emoji = emoji)
}
}

View File

@@ -43,9 +43,11 @@ class HandleRealtimeEventsUseCase @Inject constructor(
senderUsername = null,
senderAvatarUrl = null,
replyToMessageId = event.replyToMessageId,
forwardedFromMessageId = null,
type = event.type ?: "text",
text = event.text,
status = null,
attachmentWaveformJson = null,
createdAt = event.createdAt ?: java.time.Instant.now().toString(),
updatedAt = null,
)

View File

@@ -60,6 +60,10 @@ fun ChatRoute(
onReplySelected = viewModel::onReplySelected,
onEditSelected = viewModel::onEditSelected,
onDeleteSelected = viewModel::onDeleteSelected,
onForwardSelected = viewModel::onForwardSelected,
onForwardDismiss = viewModel::onForwardDismiss,
onForwardTargetSelected = viewModel::onForwardTargetSelected,
onToggleReaction = viewModel::onToggleReaction,
onCancelComposeAction = viewModel::onCancelComposeAction,
onLoadMore = viewModel::loadMore,
onPickMedia = { pickMediaLauncher.launch("*/*") },
@@ -76,6 +80,10 @@ fun ChatScreen(
onReplySelected: (MessageItem) -> Unit,
onEditSelected: (MessageItem) -> Unit,
onDeleteSelected: (Boolean) -> Unit,
onForwardSelected: () -> Unit,
onForwardDismiss: () -> Unit,
onForwardTargetSelected: (Long) -> Unit,
onToggleReaction: (String) -> Unit,
onCancelComposeAction: () -> Unit,
onLoadMore: () -> Unit,
onPickMedia: () -> Unit,
@@ -120,6 +128,7 @@ fun ChatScreen(
MessageBubble(
message = message,
isSelected = state.selectedMessage?.id == message.id,
reactions = state.reactionByMessageId[message.id].orEmpty(),
onLongPress = { onSelectMessage(message) },
)
}
@@ -135,12 +144,52 @@ fun ChatScreen(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(onClick = { onReplySelected(state.selectedMessage) }) { Text("Reply") }
Button(onClick = { onEditSelected(state.selectedMessage) }) { Text("Edit") }
Button(
onClick = { onEditSelected(state.selectedMessage) },
enabled = state.selectedCanEdit,
) { Text("Edit") }
Button(onClick = { onDeleteSelected(false) }) { Text("Delete") }
Button(
onClick = { onDeleteSelected(true) },
enabled = state.selectedCanDeleteForAll,
) { Text("Del for all") }
Button(onClick = onForwardSelected) { Text("Forward") }
Button(onClick = { onToggleReaction("\uD83D\uDC4D") }) { Text("\uD83D\uDC4D") }
Button(onClick = { onToggleReaction("\uD83D\uDE02") }) { Text("\uD83D\uDE02") }
Button(onClick = { onSelectMessage(null) }) { Text("Close") }
}
}
if (state.forwardingMessage != null) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.secondaryContainer)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = "Forward message #${state.forwardingMessage.id}",
style = MaterialTheme.typography.labelLarge,
)
if (state.availableForwardTargets.isEmpty()) {
Text("No available chats")
} else {
state.availableForwardTargets.forEach { target ->
Button(
onClick = { onForwardTargetSelected(target.chatId) },
enabled = !state.isForwarding,
) {
Text(target.title)
}
}
}
Button(onClick = onForwardDismiss, enabled = !state.isForwarding) {
Text(if (state.isForwarding) "Forwarding..." else "Cancel")
}
}
}
if (state.replyToMessage != null || state.editingMessage != null) {
val header = if (state.editingMessage != null) {
"Editing message #${state.editingMessage.id}"
@@ -231,6 +280,7 @@ private fun Uri.readMediaPayload(context: Context): PickedMediaPayload? {
private fun MessageBubble(
message: MessageItem,
isSelected: Boolean,
reactions: List<ru.daemonlord.messenger.domain.message.model.MessageReaction>,
onLongPress: () -> Unit,
) {
val isOutgoing = message.isOutgoing
@@ -264,11 +314,33 @@ private fun MessageBubble(
text = message.text ?: "[${message.type}]",
style = MaterialTheme.typography.bodyMedium,
)
if (message.forwardedFromMessageId != null) {
Text(
text = "Forwarded from #${message.forwardedFromMessageId}",
style = MaterialTheme.typography.labelSmall,
)
}
if (reactions.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
reactions.forEach { reaction ->
Text(
text = "${reaction.emoji} ${reaction.count}",
style = MaterialTheme.typography.labelSmall,
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
val status = if (message.status.isNullOrBlank()) "" else " ${message.status}"
val status = when (message.status) {
"read" -> " \u2713\u2713"
"delivered" -> " \u2713\u2713"
"sent" -> " \u2713"
"pending" -> " ..."
else -> ""
}
Text(
text = "${message.id}$status",
style = MaterialTheme.typography.labelSmall,

View File

@@ -8,19 +8,28 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.message.model.MessageItem
import ru.daemonlord.messenger.domain.message.usecase.DeleteMessageUseCase
import ru.daemonlord.messenger.domain.message.usecase.EditMessageUseCase
import ru.daemonlord.messenger.domain.message.usecase.ForwardMessageUseCase
import ru.daemonlord.messenger.domain.message.usecase.ListMessageReactionsUseCase
import ru.daemonlord.messenger.domain.message.usecase.LoadMoreMessagesUseCase
import ru.daemonlord.messenger.domain.message.usecase.MarkMessageDeliveredUseCase
import ru.daemonlord.messenger.domain.message.usecase.MarkMessageReadUseCase
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.message.usecase.ToggleMessageReactionUseCase
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
import java.time.Instant
import java.time.temporal.ChronoUnit
import javax.inject.Inject
@HiltViewModel
@@ -33,12 +42,20 @@ class ChatViewModel @Inject constructor(
private val sendMediaMessageUseCase: SendMediaMessageUseCase,
private val editMessageUseCase: EditMessageUseCase,
private val deleteMessageUseCase: DeleteMessageUseCase,
private val markMessageDeliveredUseCase: MarkMessageDeliveredUseCase,
private val markMessageReadUseCase: MarkMessageReadUseCase,
private val forwardMessageUseCase: ForwardMessageUseCase,
private val listMessageReactionsUseCase: ListMessageReactionsUseCase,
private val toggleMessageReactionUseCase: ToggleMessageReactionUseCase,
private val observeChatsUseCase: ObserveChatsUseCase,
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
) : ViewModel() {
private val chatId: Long = checkNotNull(savedStateHandle["chatId"])
private val _uiState = MutableStateFlow(MessageUiState(chatId = chatId))
val uiState: StateFlow<MessageUiState> = _uiState.asStateFlow()
private var lastDeliveredMessageId: Long? = null
private var lastReadMessageId: Long? = null
init {
handleRealtimeEventsUseCase.start()
@@ -51,7 +68,16 @@ class ChatViewModel @Inject constructor(
}
fun onSelectMessage(message: MessageItem?) {
_uiState.update { it.copy(selectedMessage = message) }
_uiState.update {
it.copy(
selectedMessage = message,
selectedCanEdit = message?.let(::canEdit) == true,
selectedCanDeleteForAll = message?.let(::canDeleteForAll) == true,
)
}
if (message != null) {
loadReactions(messageId = message.id)
}
}
fun onReplySelected(message: MessageItem) {
@@ -60,6 +86,8 @@ class ChatViewModel @Inject constructor(
replyToMessage = message,
editingMessage = null,
selectedMessage = null,
selectedCanEdit = false,
selectedCanDeleteForAll = false,
)
}
}
@@ -70,6 +98,8 @@ class ChatViewModel @Inject constructor(
editingMessage = message,
replyToMessage = null,
selectedMessage = null,
selectedCanEdit = false,
selectedCanDeleteForAll = false,
inputText = message.text.orEmpty(),
)
}
@@ -80,15 +110,103 @@ class ChatViewModel @Inject constructor(
it.copy(
replyToMessage = null,
editingMessage = null,
selectedMessage = null,
selectedCanEdit = false,
selectedCanDeleteForAll = false,
)
}
}
fun onDeleteSelected(forAll: Boolean = false) {
val selected = uiState.value.selectedMessage ?: return
if (forAll && !canDeleteForAll(selected)) {
_uiState.update { it.copy(errorMessage = "Delete for all is available only for your own messages.") }
return
}
viewModelScope.launch {
when (val result = deleteMessageUseCase(selected.id, forAll = forAll)) {
is AppResult.Success -> _uiState.update { it.copy(selectedMessage = null) }
is AppResult.Success -> _uiState.update {
it.copy(
selectedMessage = null,
selectedCanEdit = false,
selectedCanDeleteForAll = false,
)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun onForwardSelected() {
val message = uiState.value.selectedMessage ?: return
viewModelScope.launch {
val targets = observeChatsUseCase(archived = false)
.first()
.asSequence()
.filter { it.id != chatId }
.take(20)
.map { ForwardTargetUiModel(chatId = it.id, title = it.displayTitle) }
.toList()
_uiState.update {
it.copy(
forwardingMessage = message,
availableForwardTargets = targets,
selectedMessage = null,
selectedCanEdit = false,
selectedCanDeleteForAll = false,
errorMessage = null,
)
}
}
}
fun onForwardDismiss() {
_uiState.update {
it.copy(
forwardingMessage = null,
availableForwardTargets = emptyList(),
isForwarding = false,
)
}
}
fun onForwardTargetSelected(targetChatId: Long) {
val message = uiState.value.forwardingMessage ?: return
viewModelScope.launch {
_uiState.update { it.copy(isForwarding = true, errorMessage = null) }
when (val result = forwardMessageUseCase(messageId = message.id, targetChatId = targetChatId)) {
is AppResult.Success -> {
_uiState.update {
it.copy(
isForwarding = false,
forwardingMessage = null,
availableForwardTargets = emptyList(),
)
}
}
is AppResult.Error -> {
_uiState.update {
it.copy(
isForwarding = false,
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
}
fun onToggleReaction(emoji: String) {
val selected = uiState.value.selectedMessage ?: return
viewModelScope.launch {
when (val result = toggleMessageReactionUseCase(messageId = selected.id, emoji = emoji)) {
is AppResult.Success -> {
_uiState.update {
it.copy(
reactionByMessageId = it.reactionByMessageId + (selected.id to result.data),
)
}
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
@@ -101,6 +219,15 @@ class ChatViewModel @Inject constructor(
viewModelScope.launch {
_uiState.update { it.copy(isSending = true, errorMessage = null) }
val editing = uiState.value.editingMessage
if (editing != null && !canEdit(editing)) {
_uiState.update {
it.copy(
isSending = false,
errorMessage = "This message can no longer be edited.",
)
}
return@launch
}
val result = if (editing != null) {
editMessageUseCase(messageId = editing.id, newText = text)
} else {
@@ -198,6 +325,7 @@ class ChatViewModel @Inject constructor(
messages = messages.sortedBy { msg -> msg.id },
)
}
acknowledgeLatestIncoming(messages)
}
}
}
@@ -217,6 +345,49 @@ class ChatViewModel @Inject constructor(
}
}
private fun loadReactions(messageId: Long) {
viewModelScope.launch {
when (val result = listMessageReactionsUseCase(messageId = messageId)) {
is AppResult.Success -> {
_uiState.update {
it.copy(reactionByMessageId = it.reactionByMessageId + (messageId to result.data))
}
}
is AppResult.Error -> Unit
}
}
}
private fun acknowledgeLatestIncoming(messages: List<MessageItem>) {
val latestIncoming = messages
.asReversed()
.firstOrNull { !it.isOutgoing }
?: return
if (lastDeliveredMessageId != latestIncoming.id) {
lastDeliveredMessageId = latestIncoming.id
viewModelScope.launch {
markMessageDeliveredUseCase(chatId = chatId, messageId = latestIncoming.id)
}
}
if (lastReadMessageId != latestIncoming.id) {
lastReadMessageId = latestIncoming.id
viewModelScope.launch {
markMessageReadUseCase(chatId = chatId, messageId = latestIncoming.id)
}
}
}
fun canEdit(message: MessageItem): Boolean {
if (!message.isOutgoing || message.type != "text") return false
val createdAt = runCatching { Instant.parse(message.createdAt) }.getOrNull() ?: return false
return createdAt.isAfter(Instant.now().minus(7, ChronoUnit.DAYS))
}
fun canDeleteForAll(message: MessageItem): Boolean {
return message.isOutgoing
}
private fun AppError.toUiMessage(): String {
return when (this) {
AppError.Network -> "Network error."

View File

@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.ui.chat
import ru.daemonlord.messenger.domain.message.model.MessageItem
import ru.daemonlord.messenger.domain.message.model.MessageReaction
data class MessageUiState(
val chatId: Long = 0L,
@@ -14,4 +15,15 @@ data class MessageUiState(
val replyToMessage: MessageItem? = null,
val editingMessage: MessageItem? = null,
val selectedMessage: MessageItem? = null,
val selectedCanEdit: Boolean = false,
val selectedCanDeleteForAll: Boolean = false,
val reactionByMessageId: Map<Long, List<MessageReaction>> = emptyMap(),
val forwardingMessage: MessageItem? = null,
val availableForwardTargets: List<ForwardTargetUiModel> = emptyList(),
val isForwarding: Boolean = false,
)
data class ForwardTargetUiModel(
val chatId: Long,
val title: String,
)

View File

@@ -69,9 +69,11 @@ class MessageDaoTest {
senderUsername = "user",
senderAvatarUrl = null,
replyToMessageId = null,
forwardedFromMessageId = null,
type = "text",
text = text,
status = null,
attachmentWaveformJson = null,
createdAt = "2026-03-08T12:00:00Z",
updatedAt = null,
)

View File

@@ -53,9 +53,9 @@
- [x] Reply/quote
- [x] Edit (<=7 дней)
- [x] Delete for me / for all (по правам)
- [ ] Forward в 1+ чатов
- [ ] Reactions
- [ ] Delivery/read states
- [x] Forward в 1+ чатов
- [x] Reactions
- [x] Delivery/read states
## 8. Медиа и вложения
- [x] Upload image/video/file/audio