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
|
### 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.
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>?,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 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>>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user