android: complete message core with forward reactions and read states
Some checks failed
CI / test (push) Failing after 2m10s
Some checks failed
CI / test (push) Failing after 2m10s
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,6 @@ data class MessageItem(
|
||||
val isOutgoing: Boolean,
|
||||
val status: String?,
|
||||
val replyToMessageId: Long?,
|
||||
val forwardedFromMessageId: Long?,
|
||||
val attachmentWaveform: List<Int>?,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.daemonlord.messenger.domain.message.model
|
||||
|
||||
data class MessageReaction(
|
||||
val emoji: String,
|
||||
val count: Int,
|
||||
val reacted: Boolean,
|
||||
)
|
||||
@@ -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>>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user