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 ### Step 17 - Sprint B / media tests
- Added `NetworkMediaRepositoryTest` for successful upload+attach flow. - Added `NetworkMediaRepositoryTest` for successful upload+attach flow.
- Added error-path coverage for failed presigned upload handling. - 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, MessageEntity::class,
MessageAttachmentEntity::class, MessageAttachmentEntity::class,
], ],
version = 2, version = 3,
exportSchema = false, exportSchema = false,
) )
abstract class MessengerDatabase : RoomDatabase() { abstract class MessengerDatabase : RoomDatabase() {

View File

@@ -8,7 +8,11 @@ import retrofit2.http.PUT
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto 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.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 import ru.daemonlord.messenger.data.message.dto.MessageUpdateRequestDto
interface MessageApiService { interface MessageApiService {
@@ -35,4 +39,26 @@ interface MessageApiService {
@Path("message_id") messageId: Long, @Path("message_id") messageId: Long,
@Query("for_all") forAll: Boolean = false, @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, val senderId: Long,
@SerialName("reply_to_message_id") @SerialName("reply_to_message_id")
val replyToMessageId: Long? = null, val replyToMessageId: Long? = null,
@SerialName("forwarded_from_message_id")
val forwardedFromMessageId: Long? = null,
val type: String, val type: String,
val text: String? = null, val text: String? = null,
@SerialName("delivery_status") @SerialName("delivery_status")
val deliveryStatus: String? = null, val deliveryStatus: String? = null,
@SerialName("attachment_waveform")
val attachmentWaveform: List<Int>? = null,
@SerialName("created_at") @SerialName("created_at")
val createdAt: String, val createdAt: String,
@SerialName("updated_at") @SerialName("updated_at")
@@ -38,3 +42,32 @@ data class MessageCreateRequestDto(
data class MessageUpdateRequestDto( data class MessageUpdateRequestDto(
val text: String, 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?, val senderAvatarUrl: String?,
@ColumnInfo(name = "reply_to_message_id") @ColumnInfo(name = "reply_to_message_id")
val replyToMessageId: Long?, val replyToMessageId: Long?,
@ColumnInfo(name = "forwarded_from_message_id")
val forwardedFromMessageId: Long?,
@ColumnInfo(name = "type") @ColumnInfo(name = "type")
val type: String, val type: String,
@ColumnInfo(name = "text") @ColumnInfo(name = "text")
val text: String?, val text: String?,
@ColumnInfo(name = "status") @ColumnInfo(name = "status")
val status: String?, val status: String?,
@ColumnInfo(name = "attachment_waveform_json")
val attachmentWaveformJson: String?,
@ColumnInfo(name = "created_at") @ColumnInfo(name = "created_at")
val createdAt: String, val createdAt: String,
@ColumnInfo(name = "updated_at") @ColumnInfo(name = "updated_at")

View File

@@ -1,9 +1,14 @@
package ru.daemonlord.messenger.data.message.mapper 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.dto.MessageReadDto
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity 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 import ru.daemonlord.messenger.domain.message.model.MessageItem
private val mapperJson = Json { ignoreUnknownKeys = true }
fun MessageReadDto.toEntity(): MessageEntity { fun MessageReadDto.toEntity(): MessageEntity {
return MessageEntity( return MessageEntity(
id = id, id = id,
@@ -13,9 +18,11 @@ fun MessageReadDto.toEntity(): MessageEntity {
senderUsername = null, senderUsername = null,
senderAvatarUrl = null, senderAvatarUrl = null,
replyToMessageId = replyToMessageId, replyToMessageId = replyToMessageId,
forwardedFromMessageId = forwardedFromMessageId,
type = type, type = type,
text = text, text = text,
status = deliveryStatus, status = deliveryStatus,
attachmentWaveformJson = attachmentWaveform?.let { mapperJson.encodeToString(it) },
createdAt = createdAt, createdAt = createdAt,
updatedAt = updatedAt, updatedAt = updatedAt,
) )
@@ -34,5 +41,20 @@ fun MessageEntity.toDomain(currentUserId: Long?): MessageItem {
isOutgoing = currentUserId != null && currentUserId == senderId, isOutgoing = currentUserId != null && currentUserId == senderId,
status = status, status = status,
replyToMessageId = replyToMessageId, 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 package ru.daemonlord.messenger.data.message.repository
import kotlinx.coroutines.CoroutineDispatcher 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.withContext
import kotlinx.coroutines.launch
import retrofit2.HttpException import retrofit2.HttpException
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
import ru.daemonlord.messenger.data.message.api.MessageApiService 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.dao.MessageDao
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.data.message.mapper.toDomain import ru.daemonlord.messenger.data.message.mapper.toDomain
@@ -14,15 +21,22 @@ 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.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.model.MessageReaction
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
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 ru.daemonlord.messenger.data.message.dto.MessageUpdateRequestDto
import java.io.IOException import java.io.IOException
import java.util.Base64
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@Singleton @Singleton
class NetworkMessageRepository @Inject constructor( class NetworkMessageRepository @Inject constructor(
@@ -30,10 +44,21 @@ class NetworkMessageRepository @Inject constructor(
private val messageDao: MessageDao, private val messageDao: MessageDao,
private val chatDao: ChatDao, private val chatDao: ChatDao,
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
tokenRepository: TokenRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : MessageRepository { ) : 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>> { override fun observeMessages(chatId: Long): Flow<List<MessageItem>> {
return messageDao.observeRecentMessages(chatId = chatId).map { entities -> return messageDao.observeRecentMessages(chatId = chatId).map { entities ->
@@ -85,9 +110,11 @@ class NetworkMessageRepository @Inject constructor(
senderUsername = null, senderUsername = null,
senderAvatarUrl = null, senderAvatarUrl = null,
replyToMessageId = replyToMessageId, replyToMessageId = replyToMessageId,
forwardedFromMessageId = null,
type = "text", type = "text",
text = text, text = text,
status = "pending", status = "pending",
attachmentWaveformJson = null,
createdAt = java.time.Instant.now().toString(), createdAt = java.time.Instant.now().toString(),
updatedAt = null, updatedAt = null,
) )
@@ -169,9 +196,11 @@ class NetworkMessageRepository @Inject constructor(
senderUsername = null, senderUsername = null,
senderAvatarUrl = null, senderAvatarUrl = null,
replyToMessageId = replyToMessageId, replyToMessageId = replyToMessageId,
forwardedFromMessageId = null,
type = messageType, type = messageType,
text = caption, text = caption,
status = "pending", status = "pending",
attachmentWaveformJson = null,
createdAt = java.time.Instant.now().toString(), createdAt = java.time.Instant.now().toString(),
updatedAt = null, 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 { private fun mapMimeToMessageType(mimeType: String): String {
return when { return when {
mimeType.startsWith("image/") -> "image" mimeType.startsWith("image/") -> "image"
@@ -244,4 +351,24 @@ class NetworkMessageRepository @Inject constructor(
else -> AppError.Unknown(cause = this) 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 isOutgoing: Boolean,
val status: String?, val status: String?,
val replyToMessageId: Long?, 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 kotlinx.coroutines.flow.Flow
import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.message.model.MessageItem import ru.daemonlord.messenger.domain.message.model.MessageItem
import ru.daemonlord.messenger.domain.message.model.MessageReaction
interface MessageRepository { interface MessageRepository {
fun observeMessages(chatId: Long): Flow<List<MessageItem>> fun observeMessages(chatId: Long): Flow<List<MessageItem>>
@@ -19,4 +20,9 @@ interface MessageRepository {
): AppResult<Unit> ): 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>
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, senderUsername = null,
senderAvatarUrl = null, senderAvatarUrl = null,
replyToMessageId = event.replyToMessageId, replyToMessageId = event.replyToMessageId,
forwardedFromMessageId = null,
type = event.type ?: "text", type = event.type ?: "text",
text = event.text, text = event.text,
status = null, status = null,
attachmentWaveformJson = null,
createdAt = event.createdAt ?: java.time.Instant.now().toString(), createdAt = event.createdAt ?: java.time.Instant.now().toString(),
updatedAt = null, updatedAt = null,
) )

View File

@@ -60,6 +60,10 @@ fun ChatRoute(
onReplySelected = viewModel::onReplySelected, onReplySelected = viewModel::onReplySelected,
onEditSelected = viewModel::onEditSelected, onEditSelected = viewModel::onEditSelected,
onDeleteSelected = viewModel::onDeleteSelected, onDeleteSelected = viewModel::onDeleteSelected,
onForwardSelected = viewModel::onForwardSelected,
onForwardDismiss = viewModel::onForwardDismiss,
onForwardTargetSelected = viewModel::onForwardTargetSelected,
onToggleReaction = viewModel::onToggleReaction,
onCancelComposeAction = viewModel::onCancelComposeAction, onCancelComposeAction = viewModel::onCancelComposeAction,
onLoadMore = viewModel::loadMore, onLoadMore = viewModel::loadMore,
onPickMedia = { pickMediaLauncher.launch("*/*") }, onPickMedia = { pickMediaLauncher.launch("*/*") },
@@ -76,6 +80,10 @@ fun ChatScreen(
onReplySelected: (MessageItem) -> Unit, onReplySelected: (MessageItem) -> Unit,
onEditSelected: (MessageItem) -> Unit, onEditSelected: (MessageItem) -> Unit,
onDeleteSelected: (Boolean) -> Unit, onDeleteSelected: (Boolean) -> Unit,
onForwardSelected: () -> Unit,
onForwardDismiss: () -> Unit,
onForwardTargetSelected: (Long) -> Unit,
onToggleReaction: (String) -> Unit,
onCancelComposeAction: () -> Unit, onCancelComposeAction: () -> Unit,
onLoadMore: () -> Unit, onLoadMore: () -> Unit,
onPickMedia: () -> Unit, onPickMedia: () -> Unit,
@@ -120,6 +128,7 @@ fun ChatScreen(
MessageBubble( MessageBubble(
message = message, message = message,
isSelected = state.selectedMessage?.id == message.id, isSelected = state.selectedMessage?.id == message.id,
reactions = state.reactionByMessageId[message.id].orEmpty(),
onLongPress = { onSelectMessage(message) }, onLongPress = { onSelectMessage(message) },
) )
} }
@@ -135,12 +144,52 @@ fun ChatScreen(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Button(onClick = { onReplySelected(state.selectedMessage) }) { Text("Reply") } 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(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") } 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) { if (state.replyToMessage != null || state.editingMessage != null) {
val header = if (state.editingMessage != null) { val header = if (state.editingMessage != null) {
"Editing message #${state.editingMessage.id}" "Editing message #${state.editingMessage.id}"
@@ -231,6 +280,7 @@ private fun Uri.readMediaPayload(context: Context): PickedMediaPayload? {
private fun MessageBubble( private fun MessageBubble(
message: MessageItem, message: MessageItem,
isSelected: Boolean, isSelected: Boolean,
reactions: List<ru.daemonlord.messenger.domain.message.model.MessageReaction>,
onLongPress: () -> Unit, onLongPress: () -> Unit,
) { ) {
val isOutgoing = message.isOutgoing val isOutgoing = message.isOutgoing
@@ -264,11 +314,33 @@ private fun MessageBubble(
text = message.text ?: "[${message.type}]", text = message.text ?: "[${message.type}]",
style = MaterialTheme.typography.bodyMedium, 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End, 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(
text = "${message.id}$status", text = "${message.id}$status",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,

View File

@@ -8,19 +8,28 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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.AppError
import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.message.model.MessageItem import ru.daemonlord.messenger.domain.message.model.MessageItem
import ru.daemonlord.messenger.domain.message.usecase.DeleteMessageUseCase 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.ForwardMessageUseCase
import ru.daemonlord.messenger.domain.message.usecase.ListMessageReactionsUseCase
import ru.daemonlord.messenger.domain.message.usecase.LoadMoreMessagesUseCase 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.ObserveMessagesUseCase
import ru.daemonlord.messenger.domain.message.usecase.SendMediaMessageUseCase 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.message.usecase.ToggleMessageReactionUseCase
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
import java.time.Instant
import java.time.temporal.ChronoUnit
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -33,12 +42,20 @@ class ChatViewModel @Inject constructor(
private val sendMediaMessageUseCase: SendMediaMessageUseCase, private val sendMediaMessageUseCase: SendMediaMessageUseCase,
private val editMessageUseCase: EditMessageUseCase, private val editMessageUseCase: EditMessageUseCase,
private val deleteMessageUseCase: DeleteMessageUseCase, 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, private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
) : ViewModel() { ) : ViewModel() {
private val chatId: Long = checkNotNull(savedStateHandle["chatId"]) private val chatId: Long = checkNotNull(savedStateHandle["chatId"])
private val _uiState = MutableStateFlow(MessageUiState(chatId = chatId)) private val _uiState = MutableStateFlow(MessageUiState(chatId = chatId))
val uiState: StateFlow<MessageUiState> = _uiState.asStateFlow() val uiState: StateFlow<MessageUiState> = _uiState.asStateFlow()
private var lastDeliveredMessageId: Long? = null
private var lastReadMessageId: Long? = null
init { init {
handleRealtimeEventsUseCase.start() handleRealtimeEventsUseCase.start()
@@ -51,7 +68,16 @@ class ChatViewModel @Inject constructor(
} }
fun onSelectMessage(message: MessageItem?) { 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) { fun onReplySelected(message: MessageItem) {
@@ -60,6 +86,8 @@ class ChatViewModel @Inject constructor(
replyToMessage = message, replyToMessage = message,
editingMessage = null, editingMessage = null,
selectedMessage = null, selectedMessage = null,
selectedCanEdit = false,
selectedCanDeleteForAll = false,
) )
} }
} }
@@ -70,6 +98,8 @@ class ChatViewModel @Inject constructor(
editingMessage = message, editingMessage = message,
replyToMessage = null, replyToMessage = null,
selectedMessage = null, selectedMessage = null,
selectedCanEdit = false,
selectedCanDeleteForAll = false,
inputText = message.text.orEmpty(), inputText = message.text.orEmpty(),
) )
} }
@@ -80,15 +110,103 @@ class ChatViewModel @Inject constructor(
it.copy( it.copy(
replyToMessage = null, replyToMessage = null,
editingMessage = null, editingMessage = null,
selectedMessage = null,
selectedCanEdit = false,
selectedCanDeleteForAll = false,
) )
} }
} }
fun onDeleteSelected(forAll: Boolean = false) { fun onDeleteSelected(forAll: Boolean = false) {
val selected = uiState.value.selectedMessage ?: return 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 { viewModelScope.launch {
when (val result = deleteMessageUseCase(selected.id, forAll = forAll)) { 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()) } is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
} }
} }
@@ -101,6 +219,15 @@ class ChatViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isSending = true, errorMessage = null) } _uiState.update { it.copy(isSending = true, errorMessage = null) }
val editing = uiState.value.editingMessage 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) { val result = if (editing != null) {
editMessageUseCase(messageId = editing.id, newText = text) editMessageUseCase(messageId = editing.id, newText = text)
} else { } else {
@@ -198,6 +325,7 @@ class ChatViewModel @Inject constructor(
messages = messages.sortedBy { msg -> msg.id }, 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 { private fun AppError.toUiMessage(): String {
return when (this) { return when (this) {
AppError.Network -> "Network error." AppError.Network -> "Network error."

View File

@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.ui.chat package ru.daemonlord.messenger.ui.chat
import ru.daemonlord.messenger.domain.message.model.MessageItem import ru.daemonlord.messenger.domain.message.model.MessageItem
import ru.daemonlord.messenger.domain.message.model.MessageReaction
data class MessageUiState( data class MessageUiState(
val chatId: Long = 0L, val chatId: Long = 0L,
@@ -14,4 +15,15 @@ data class MessageUiState(
val replyToMessage: MessageItem? = null, val replyToMessage: MessageItem? = null,
val editingMessage: MessageItem? = null, val editingMessage: MessageItem? = null,
val selectedMessage: 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", senderUsername = "user",
senderAvatarUrl = null, senderAvatarUrl = null,
replyToMessageId = null, replyToMessageId = null,
forwardedFromMessageId = null,
type = "text", type = "text",
text = text, text = text,
status = null, status = null,
attachmentWaveformJson = null,
createdAt = "2026-03-08T12:00:00Z", createdAt = "2026-03-08T12:00:00Z",
updatedAt = null, updatedAt = null,
) )

View File

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